Show channel banner in channel (#30514)
* Adde MySQL and Postgres migrations * Replaced select * with column names * removed all * from channel SQL store * cleanup * Fixed a duplicate column * cleanup * Added migrations and store support * WIP * used channelname slice in a missed place * Handled patch * Added app level tests * Added API layer tests * Added API layer tests * WIP * converted to query builder * cleanupo * added not null and default constraints * Fixed test * fixed file name * review fixes * review fixes * updated migration file * fixed text * Review fixes * WIP * rendered banner * WPI * Fixed tooltip and markdown styling * test: Add comprehensive tests for ChannelBanner component * refactor: Use renderWithContext in channel_banner test * WIP * updated channel banner test * Updated channel view test * getContrastingSimpleColor tests * Added tests * rendered channel banner * feat: Add comprehensive tests for ChannelBanner component * Updated tests * Made a lazy component * Added underline to links in channel banner * renamed param * Created a selector for checking if channel banner is enabled or disabled * addded test file * test: Add tests for selectShowChannelBanner selector * Added tests * lint fix * Used premium SKU constants * Fixed a redux test
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
09488558a0
Коммит
d2173eb664
@@ -0,0 +1,363 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import type {ChannelType} from '@mattermost/types/channels';
|
||||
|
||||
import {renderWithContext} from 'tests/react_testing_utils';
|
||||
import {LicenseSkus, Constants} from 'utils/constants';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import ChannelBanner from './index';
|
||||
|
||||
describe('components/channel_banner', () => {
|
||||
const channel1 = TestHelper.getChannelMock({
|
||||
id: 'channel_id_1',
|
||||
team_id: 'team_id',
|
||||
display_name: 'Test Channel 1',
|
||||
header: 'This is the channel header',
|
||||
name: 'test-channel',
|
||||
type: Constants.OPEN_CHANNEL as ChannelType,
|
||||
banner_info: {
|
||||
text: 'Test banner message',
|
||||
background_color: '#FF0000',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const channel2 = TestHelper.getChannelMock({
|
||||
id: 'channel_id_2',
|
||||
team_id: 'team_id',
|
||||
display_name: 'Test Channel 2',
|
||||
header: 'This is the channel header',
|
||||
name: 'test-channel',
|
||||
type: Constants.OPEN_CHANNEL as ChannelType,
|
||||
banner_info: {
|
||||
text: 'Disabled banner',
|
||||
background_color: '#00FF00',
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const channel3 = TestHelper.getChannelMock({
|
||||
id: 'channel_id_3',
|
||||
team_id: 'team_id',
|
||||
display_name: 'Test Channel 3',
|
||||
header: 'This is the channel header',
|
||||
name: 'test-channel',
|
||||
type: Constants.OPEN_CHANNEL as ChannelType,
|
||||
banner_info: {
|
||||
text: 'Banner with **markdown**',
|
||||
background_color: '#0000FF',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const dmChannel = TestHelper.getChannelMock({
|
||||
id: 'dm_channel_id',
|
||||
team_id: 'team_id',
|
||||
display_name: 'DM Channel',
|
||||
name: 'dm-channel',
|
||||
type: Constants.DM_CHANNEL as ChannelType,
|
||||
banner_info: {
|
||||
text: 'DM Banner',
|
||||
background_color: '#FF00FF',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const privateChannel = TestHelper.getChannelMock({
|
||||
id: 'private_channel_id',
|
||||
team_id: 'team_id',
|
||||
display_name: 'Private Channel',
|
||||
name: 'private-channel',
|
||||
type: Constants.PRIVATE_CHANNEL as ChannelType,
|
||||
banner_info: {
|
||||
text: 'Private Channel Banner',
|
||||
background_color: '#FFFF00',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const baseState = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
SkuShortName: LicenseSkus.Premium,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
channels: {
|
||||
[channel1.id]: channel1,
|
||||
[channel2.id]: channel2,
|
||||
[channel3.id]: channel3,
|
||||
[dmChannel.id]: dmChannel,
|
||||
[privateChannel.id]: privateChannel,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'current-user-id',
|
||||
profiles: {
|
||||
'current-user-id': TestHelper.getUserMock({
|
||||
id: 'current-user-id',
|
||||
username: 'current-user',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('should not render when license is professional', () => {
|
||||
const nonEnterpriseLicenseState = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
SkuShortName: LicenseSkus.Professional,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'channel_id_1'}/>,
|
||||
nonEnterpriseLicenseState,
|
||||
);
|
||||
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not render when license is enterprise', () => {
|
||||
const nonEnterpriseLicenseState = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
SkuShortName: LicenseSkus.Enterprise,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'channel_id_1'}/>,
|
||||
nonEnterpriseLicenseState,
|
||||
);
|
||||
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not render when there is no license', () => {
|
||||
const noLicenseState = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
general: {},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'channel_id_1'}/>,
|
||||
noLicenseState,
|
||||
);
|
||||
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not render when banner is disabled', () => {
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'channel_id_2'}/>,
|
||||
baseState,
|
||||
);
|
||||
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not render when channel has no banner', () => {
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'non-existent-channel-id'}/>,
|
||||
baseState,
|
||||
);
|
||||
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not render when banner info is incomplete', () => {
|
||||
const channel = TestHelper.getChannelMock({
|
||||
id: 'channel_id_1',
|
||||
team_id: 'team_id',
|
||||
display_name: 'Test Channel 1',
|
||||
header: 'This is the channel header',
|
||||
name: 'test-channel',
|
||||
banner_info: {
|
||||
|
||||
// incomplete channel banner info
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const incompleteBannerInfoState = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
channels: {
|
||||
...baseState.entities.channels,
|
||||
channels: {
|
||||
...baseState.entities.channels.channels,
|
||||
[channel.id]: channel,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'channel_id_1'}/>,
|
||||
incompleteBannerInfoState,
|
||||
);
|
||||
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render banner with correct text and styling', () => {
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'channel_id_1'}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
const bannerContainer = screen.getByTestId('channel_banner_container');
|
||||
expect(bannerContainer).toBeInTheDocument();
|
||||
expect(bannerContainer).toHaveStyle('background-color: #FF0000');
|
||||
|
||||
const bannerText = screen.getByTestId('channel_banner_text');
|
||||
expect(bannerText).toBeInTheDocument();
|
||||
expect(bannerText.textContent).toBe('Test banner message');
|
||||
});
|
||||
|
||||
test('should render markdown in banner text', () => {
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'channel_id_3'}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
const bannerContainer = screen.getByTestId('channel_banner_container');
|
||||
expect(bannerContainer).toBeInTheDocument();
|
||||
expect(bannerContainer).toHaveStyle('background-color: #0000FF');
|
||||
|
||||
const bannerText = screen.getByTestId('channel_banner_text');
|
||||
expect(bannerText).toBeInTheDocument();
|
||||
|
||||
// Check that markdown was rendered (bold text)
|
||||
const strongElement = bannerText.querySelector('strong');
|
||||
expect(strongElement).toBeInTheDocument();
|
||||
expect(strongElement?.textContent).toBe('markdown');
|
||||
});
|
||||
|
||||
test('should render for private channels', () => {
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'private_channel_id'}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
const bannerContainer = screen.getByTestId('channel_banner_container');
|
||||
expect(bannerContainer).toBeInTheDocument();
|
||||
expect(bannerContainer).toHaveStyle('background-color: #FFFF00');
|
||||
|
||||
const bannerText = screen.getByTestId('channel_banner_text');
|
||||
expect(bannerText).toBeInTheDocument();
|
||||
expect(bannerText.textContent).toBe('Private Channel Banner');
|
||||
});
|
||||
|
||||
test('should not render for DM channels', () => {
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'dm_channel_id'}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('channel_banner_container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should apply contrasting text color based on dark background color', () => {
|
||||
// dark background should have dark text
|
||||
const darkBgChannel = TestHelper.getChannelMock({
|
||||
id: 'light_bg_channel',
|
||||
team_id: 'team_id',
|
||||
display_name: 'Light BG Channel',
|
||||
name: 'light-bg-channel',
|
||||
type: Constants.OPEN_CHANNEL as ChannelType,
|
||||
banner_info: {
|
||||
text: 'Light background banner',
|
||||
background_color: '#000000',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const stateWithLightBgChannel = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
channels: {
|
||||
...baseState.entities.channels,
|
||||
channels: {
|
||||
...baseState.entities.channels.channels,
|
||||
[darkBgChannel.id]: darkBgChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'light_bg_channel'}/>,
|
||||
stateWithLightBgChannel,
|
||||
);
|
||||
|
||||
// This test might be flaky depending on how getContrastingSimpleColor is implemented
|
||||
// We're expecting dark text on light background
|
||||
const darkBgBannerText = screen.getByTestId('channel_banner_text');
|
||||
expect(darkBgBannerText).toBeInTheDocument();
|
||||
expect(darkBgBannerText).toHaveStyle('color: rgb(255, 255, 255)');
|
||||
expect(darkBgBannerText).toHaveStyle('--channel-banner-text-color: #FFFFFF');
|
||||
});
|
||||
|
||||
test('should apply contrasting text color based on light background color', () => {
|
||||
// Light background should have dark text
|
||||
const lightBgChannel = TestHelper.getChannelMock({
|
||||
id: 'light_bg_channel',
|
||||
team_id: 'team_id',
|
||||
display_name: 'Light BG Channel',
|
||||
name: 'light-bg-channel',
|
||||
type: Constants.OPEN_CHANNEL as ChannelType,
|
||||
banner_info: {
|
||||
text: 'Light background banner',
|
||||
background_color: '#FFFFFF',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const stateWithLightBgChannel = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
channels: {
|
||||
...baseState.entities.channels,
|
||||
channels: {
|
||||
...baseState.entities.channels.channels,
|
||||
[lightBgChannel.id]: lightBgChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<ChannelBanner channelId={'light_bg_channel'}/>,
|
||||
stateWithLightBgChannel,
|
||||
);
|
||||
|
||||
// This test might be flaky depending on how getContrastingSimpleColor is implemented
|
||||
// We're expecting dark text on light background
|
||||
const lightBgBannerText = screen.getByTestId('channel_banner_text');
|
||||
expect(lightBgBannerText).toBeInTheDocument();
|
||||
expect(lightBgBannerText).toHaveStyle('color: rgb(0, 0, 0)');
|
||||
expect(lightBgBannerText).toHaveStyle('--channel-banner-text-color: #000000');
|
||||
});
|
||||
});
|
||||
95
webapp/channels/src/components/channel_banner/index.tsx
Обычный файл
95
webapp/channels/src/components/channel_banner/index.tsx
Обычный файл
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {selectShowChannelBanner} from 'mattermost-redux/selectors/entities/channel_banner';
|
||||
import {getChannelBanner} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getContrastingSimpleColor} from 'mattermost-redux/utils/theme_utils';
|
||||
|
||||
import Markdown from 'components/markdown';
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
import type {TextFormattingOptions} from 'utils/text_formatting';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import './style.scss';
|
||||
|
||||
const markdownRenderingOptions: Partial<TextFormattingOptions> = {
|
||||
singleline: true,
|
||||
mentionHighlight: false,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export default function ChannelBanner({channelId}: Props) {
|
||||
const channelBannerInfo = useSelector((state: GlobalState) => getChannelBanner(state, channelId));
|
||||
const showChannelBanner = useSelector((state: GlobalState) => selectShowChannelBanner(state, channelId));
|
||||
|
||||
const intl = useIntl();
|
||||
const channelBannerTextAriaLabel = intl.formatMessage({id: 'channel_banner.aria_label', defaultMessage: 'Channel banner text'});
|
||||
|
||||
const content = (
|
||||
<Markdown
|
||||
message={channelBannerInfo?.text}
|
||||
options={markdownRenderingOptions}
|
||||
/>
|
||||
);
|
||||
|
||||
const channelBannerStyle = useMemo(() => {
|
||||
return {
|
||||
backgroundColor: channelBannerInfo?.background_color,
|
||||
};
|
||||
}, [channelBannerInfo]);
|
||||
|
||||
const channelBannerTextStyle = useMemo(() => {
|
||||
// this is just to satisfy type checks.
|
||||
if (!channelBannerInfo || !channelBannerInfo.background_color) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const color = getContrastingSimpleColor(channelBannerInfo.background_color);
|
||||
|
||||
// The CSS variable is declared here, and is being used in the stylesheet being imported in this component.
|
||||
// This is needed because if the user sets background color a share of blue similar to the default link color,
|
||||
// the markdown link will become almost invisible. So, the CSS variable declared here is used
|
||||
// to set the color of the text in anchor tag in the stylesheet.
|
||||
return {
|
||||
color,
|
||||
'--channel-banner-text-color': color,
|
||||
};
|
||||
}, [channelBannerInfo]);
|
||||
|
||||
if (!channelBannerInfo || !showChannelBanner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WithTooltip
|
||||
title={content}
|
||||
className='channelBannerTooltip'
|
||||
delayClose={true}
|
||||
forcedPlacement='bottom'
|
||||
>
|
||||
<div
|
||||
className='channel_banner'
|
||||
data-testid='channel_banner_container'
|
||||
style={channelBannerStyle}
|
||||
>
|
||||
<span
|
||||
data-testid='channel_banner_text'
|
||||
className='channel_banner_text'
|
||||
aria-label={channelBannerTextAriaLabel}
|
||||
style={channelBannerTextStyle}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
</div>
|
||||
</WithTooltip>
|
||||
);
|
||||
}
|
||||
35
webapp/channels/src/components/channel_banner/style.scss
Обычный файл
35
webapp/channels/src/components/channel_banner/style.scss
Обычный файл
@@ -0,0 +1,35 @@
|
||||
@use "utils/variables";
|
||||
|
||||
.channel_banner {
|
||||
display: flex;
|
||||
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;
|
||||
white-space: nowrap;
|
||||
|
||||
.channel_banner_text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
a {
|
||||
color: var(--channel-banner-text-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.channelBannerTooltip {
|
||||
overflow: auto;
|
||||
min-width: 400px;
|
||||
max-width: min(1000px, 70vw);
|
||||
max-height: 80vh;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ exports[`components/channel_view Should match snapshot if channel is archived 1`
|
||||
teamUrl="/team"
|
||||
viewArchivedChannels={false}
|
||||
/>
|
||||
<ChannelBanner
|
||||
channelId="channelId"
|
||||
/>
|
||||
<DeferredRenderWrapper
|
||||
channelId="channelId"
|
||||
/>
|
||||
@@ -96,6 +99,9 @@ exports[`components/channel_view Should match snapshot if channel is deactivated
|
||||
teamUrl="/team"
|
||||
viewArchivedChannels={false}
|
||||
/>
|
||||
<ChannelBanner
|
||||
channelId="channelId"
|
||||
/>
|
||||
<DeferredRenderWrapper
|
||||
channelId="channelId"
|
||||
/>
|
||||
@@ -160,6 +166,9 @@ exports[`components/channel_view Should match snapshot with base props 1`] = `
|
||||
teamUrl="/team"
|
||||
viewArchivedChannels={false}
|
||||
/>
|
||||
<ChannelBanner
|
||||
channelId="channelId"
|
||||
/>
|
||||
<DeferredRenderWrapper
|
||||
channelId="channelId"
|
||||
/>
|
||||
|
||||
@@ -20,6 +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')));
|
||||
|
||||
export type Props = PropsFromRedux & RouteComponentProps<{
|
||||
postid?: string;
|
||||
@@ -193,6 +194,7 @@ export default class ChannelView extends React.PureComponent<Props, State> {
|
||||
/>
|
||||
<ChannelHeader {...this.props}/>
|
||||
{this.props.isChannelBookmarksEnabled && <ChannelBookmarks channelId={this.props.channelId}/>}
|
||||
<ChannelBanner channelId={this.props.channelId}/>
|
||||
<DeferredPostView
|
||||
channelId={this.props.channelId}
|
||||
focusedPostId={this.state.focusedPostId}
|
||||
|
||||
@@ -83,6 +83,8 @@ interface Props {
|
||||
*/
|
||||
onOpen?: () => void;
|
||||
children: ReactElement;
|
||||
|
||||
forcedPlacement?: Placement;
|
||||
}
|
||||
|
||||
export default function WithTooltip({
|
||||
@@ -97,6 +99,7 @@ export default function WithTooltip({
|
||||
className,
|
||||
onOpen,
|
||||
disabled,
|
||||
forcedPlacement,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -111,6 +114,11 @@ export default function WithTooltip({
|
||||
}
|
||||
|
||||
const placements = useMemo<{initial: Placement; fallback: Placement[]}>(() => {
|
||||
// if an explicit placement is provided, use it exclusively
|
||||
if (forcedPlacement) {
|
||||
return {initial: forcedPlacement, fallback: [forcedPlacement]};
|
||||
}
|
||||
|
||||
let initial: Placement;
|
||||
let fallback: Placement[];
|
||||
if (isVertical) {
|
||||
|
||||
@@ -3212,6 +3212,7 @@
|
||||
"change_url.shorter": "URLs must have maximum 64 characters.",
|
||||
"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_bookmarks.addBookmark": "Add a bookmark",
|
||||
"channel_bookmarks.addBookmarkLimitReached": "Cannot add more than {limit} bookmarks",
|
||||
"channel_bookmarks.addLink": "Add a link",
|
||||
|
||||
@@ -76,4 +76,6 @@ export default {
|
||||
DEFAULT_GROUP: 'board',
|
||||
CUSTOM_GROUP_USER_ROLE: 'custom_group_user',
|
||||
MAX_GET_ROLES_BY_NAMES: 100,
|
||||
SKUEnterprise: 'enterprise',
|
||||
SKUPremium: 'premium',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {DeepPartial} from 'redux';
|
||||
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
|
||||
import {selectShowChannelBanner} from './channel_banner';
|
||||
|
||||
describe('Selectors.ChannelBanner', () => {
|
||||
const channelId = 'channel1';
|
||||
const teamId = 'team1';
|
||||
|
||||
const baseState: DeepPartial<GlobalState> = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
SkuShortName: General.SKUPremium,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
channels: {
|
||||
channel1: {
|
||||
id: channelId,
|
||||
team_id: teamId,
|
||||
type: General.OPEN_CHANNEL,
|
||||
banner_info: {
|
||||
enabled: true,
|
||||
text: 'Text',
|
||||
background_color: '#000000',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('should return false when license is not premium', () => {
|
||||
const state: DeepPartial<GlobalState> = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
general: {
|
||||
license: {
|
||||
SkuShortName: 'starter',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false when license is professional', () => {
|
||||
const state: DeepPartial<GlobalState> = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
general: {
|
||||
license: {
|
||||
SkuShortName: 'professional',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false when license is enterprise', () => {
|
||||
const state: DeepPartial<GlobalState> = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
general: {
|
||||
license: {
|
||||
SkuShortName: 'enterprise',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false when channel type is not open or private', () => {
|
||||
const state: DeepPartial<GlobalState> = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
channels: {
|
||||
channels: {
|
||||
channel1: {
|
||||
id: channelId,
|
||||
team_id: teamId,
|
||||
type: General.OPEN_CHANNEL,
|
||||
banner_info: {
|
||||
enabled: false,
|
||||
text: 'Text',
|
||||
background_color: '#000000',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false when channel banner is not enabled', () => {
|
||||
const state: DeepPartial<GlobalState> = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
channels: {
|
||||
channels: {
|
||||
channel1: {
|
||||
id: channelId,
|
||||
team_id: teamId,
|
||||
type: General.OPEN_CHANNEL,
|
||||
banner_info: {
|
||||
enabled: false,
|
||||
text: 'Text',
|
||||
background_color: '#000000',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return true when all conditions are met for open channel', () => {
|
||||
expect(selectShowChannelBanner(baseState as GlobalState, channelId)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true when all conditions are met for private channel', () => {
|
||||
const state: DeepPartial<GlobalState> = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
channels: {
|
||||
channels: {
|
||||
channel1: {
|
||||
id: channelId,
|
||||
team_id: teamId,
|
||||
type: General.PRIVATE_CHANNEL,
|
||||
banner_info: {
|
||||
enabled: true,
|
||||
text: 'Text',
|
||||
background_color: '#000000',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false when channel does not exist', () => {
|
||||
const state: DeepPartial<GlobalState> = {
|
||||
...baseState,
|
||||
entities: {
|
||||
...baseState.entities,
|
||||
channels: {
|
||||
channels: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(selectShowChannelBanner(state as GlobalState, channelId)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {channelBannerEnabled} from '@mattermost/types/channels';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
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 => {
|
||||
const license = getLicense(state);
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import max from 'lodash/max';
|
||||
|
||||
import type {
|
||||
Channel,
|
||||
ChannelBanner,
|
||||
ChannelMemberCountsByGroup,
|
||||
ChannelMembership,
|
||||
ChannelMessageCount,
|
||||
@@ -1445,3 +1446,8 @@ export const isDeactivatedDirectChannel = (state: GlobalState, channelId: string
|
||||
const teammate = getDirectTeammate(state, channelId);
|
||||
return Boolean(teammate && teammate.delete_at);
|
||||
};
|
||||
|
||||
export function getChannelBanner(state: GlobalState, channelId: string): ChannelBanner | undefined {
|
||||
const channel = getChannel(state, channelId);
|
||||
return channel ? channel.banner_info : undefined;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import * as ThemeUtils from 'mattermost-redux/utils/theme_utils';
|
||||
import {getContrastingSimpleColor} from 'mattermost-redux/utils/theme_utils';
|
||||
|
||||
import {Preferences} from '../constants';
|
||||
|
||||
@@ -92,3 +93,81 @@ describe('ThemeUtils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContrastingSimpleColor', () => {
|
||||
// Test for dark colors that should return white text
|
||||
it('should return white (#FFFFFF) for black', () => {
|
||||
expect(getContrastingSimpleColor('#000000')).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
it('should return white for dark blue', () => {
|
||||
expect(getContrastingSimpleColor('#0000FF')).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
it('should return white for dark red', () => {
|
||||
expect(getContrastingSimpleColor('#8B0000')).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
it('should return white for dark green', () => {
|
||||
expect(getContrastingSimpleColor('#006400')).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
// Test for light colors that should return black text
|
||||
it('should return black (#000000) for white', () => {
|
||||
expect(getContrastingSimpleColor('#FFFFFF')).toBe('#000000');
|
||||
});
|
||||
|
||||
it('should return black for light yellow', () => {
|
||||
expect(getContrastingSimpleColor('#FFFF00')).toBe('#000000');
|
||||
});
|
||||
|
||||
it('should return black for light cyan', () => {
|
||||
expect(getContrastingSimpleColor('#00FFFF')).toBe('#000000');
|
||||
});
|
||||
|
||||
it('should return black for light pink', () => {
|
||||
expect(getContrastingSimpleColor('#FFC0CB')).toBe('#000000');
|
||||
});
|
||||
|
||||
it('should not crash for invalid colors', () => {
|
||||
expect(getContrastingSimpleColor('')).toBe('');
|
||||
expect(getContrastingSimpleColor('##########')).toBe('');
|
||||
expect(getContrastingSimpleColor(' ')).toBe('');
|
||||
});
|
||||
|
||||
// Test for colors near the threshold
|
||||
it('should return black for colors just above the luminance threshold', () => {
|
||||
// for this background color, black text has a
|
||||
// contrast ratio of 4.4:1, whereas white has that of 4.6:1,
|
||||
// giving it a slight advantage.
|
||||
expect(getContrastingSimpleColor('#747474')).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
it('should return white for colors just below the luminance threshold', () => {
|
||||
// #737373 has a luminance of approximately 0.178 (just below threshold)
|
||||
expect(getContrastingSimpleColor('#737373')).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
// Test for input format variations
|
||||
it('should handle hex colors with or without # prefix', () => {
|
||||
expect(getContrastingSimpleColor('000000')).toBe('#FFFFFF');
|
||||
expect(getContrastingSimpleColor('#000000')).toBe('#FFFFFF');
|
||||
expect(getContrastingSimpleColor('FFFFFF')).toBe('#000000');
|
||||
expect(getContrastingSimpleColor('#FFFFFF')).toBe('#000000');
|
||||
});
|
||||
|
||||
// Test for more realistic use cases
|
||||
it('should return appropriate contrast colors for common UI colors', () => {
|
||||
// Mattermost denim blue
|
||||
expect(getContrastingSimpleColor('#1e325c')).toBe('#FFFFFF');
|
||||
|
||||
// Mattermost Onyx grey
|
||||
expect(getContrastingSimpleColor('#202228')).toBe('#FFFFFF');
|
||||
|
||||
// Mattermost Indigo blue
|
||||
expect(getContrastingSimpleColor('#151e32')).toBe('#FFFFFF');
|
||||
|
||||
// Mattermost quartz white
|
||||
expect(getContrastingSimpleColor('#f4f4f6')).toBe('#000000');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,3 +170,33 @@ export function setThemeDefaults(theme: Partial<Theme>): Theme {
|
||||
|
||||
return processedTheme as Theme;
|
||||
}
|
||||
|
||||
// getContrastingSimpleColor returns a contrasting color - either black or white, depending on the luminance
|
||||
// of the supplied color. Both input and outpur colors are in hexadecimal color code.
|
||||
export function getContrastingSimpleColor(colorHexCode: string): string {
|
||||
const color = colorHexCode.startsWith('#') ? colorHexCode.slice(1) : colorHexCode;
|
||||
|
||||
if (color.length !== 6) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// split red, green and blue components
|
||||
const red = parseInt(color.substring(0, 2), 16);
|
||||
const green = parseInt(color.substring(2, 4), 16);
|
||||
const blue = parseInt(color.substring(4, 6), 16);
|
||||
|
||||
// calculate relative luminance of each color channel - https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
||||
const srgb = [red / 255, green / 255, blue / 255];
|
||||
const [redLuminance, greenLuminance, blueLuminance] = srgb.map((i) => {
|
||||
if (i <= 0.04045) {
|
||||
return i / 12.92;
|
||||
}
|
||||
return Math.pow((i + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
|
||||
// calculate luminance of the whole color by adding percieved luminance of each channel
|
||||
const colorLuminance = (0.2126 * redLuminance) + (0.7152 * greenLuminance) + (0.0722 * blueLuminance);
|
||||
|
||||
// return black or white based on color's luminance
|
||||
return colorLuminance > 0.179 ? '#000000' : '#FFFFFF';
|
||||
}
|
||||
|
||||
@@ -32,6 +32,20 @@ export type ChannelNotifyProps = {
|
||||
channel_auto_follow_threads: 'off' | 'on';
|
||||
};
|
||||
|
||||
export type ChannelBanner = {
|
||||
enabled?: boolean;
|
||||
text?: string;
|
||||
background_color?: string;
|
||||
}
|
||||
|
||||
export function channelBannerEnabled(banner: ChannelBanner | undefined): boolean {
|
||||
if (!banner) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(banner.enabled) && Boolean(banner.text) && Boolean(banner.background_color);
|
||||
}
|
||||
|
||||
export type Channel = {
|
||||
id: string;
|
||||
create_at: number;
|
||||
@@ -53,6 +67,7 @@ export type Channel = {
|
||||
shared?: boolean;
|
||||
props?: Record<string, any>;
|
||||
policy_id?: string | null;
|
||||
banner_info?: ChannelBanner;
|
||||
};
|
||||
|
||||
export type ServerChannel = Channel & {
|
||||
|
||||
Ссылка в новой задаче
Block a user