[MM-68781] MBE Phase 8d: add hooks ChannelComposerBanner & ChannelIntro (#36581)

* phase 8d

* review: key PluggableErrorBoundary by channel + add missing-channel intro test

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* simplify: remove redundant channelId from call-site keys

PluggableErrorBoundary now carries key={registration.id:channel.id}
internally, so the outer ChannelDecoratorRenderer no longer needs a
channel-scoped key to reset the boundary on navigation.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* merge conflicts

* more conflicts

* remove unregisterChannelDecorator + REMOVED_PLUGIN_COMPONENT_BY_ID

The unregister method dispatched an action whose reducer case was removed
during Phase 8b's cleanup, making the call a silent no-op (surfaced by a
failing CI test). Restore is not warranted: plugin lifecycle already sweeps
all registrations on uninstall and bundle-path-changed reload via
REMOVED_WEBAPP_PLUGIN, and the matcher contract handles dynamic on/off
without re-registration. Drop the unregister method, the action constant,
and the failing scoped-removal tests; move clearLoggedDecoratorErrors into
register so the log-once tracker still resets on re-registration. Replace
the deleted tests with a REMOVED_WEBAPP_PLUGIN sweep test mirroring the 8b
pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* remove mount_overlay, left_of_channel_name; add after_channel_name

* simplifications: remove slots, no after name decorator, intro fixed

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christopher Poile
2026-06-15 08:40:49 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 0a738be909
commit c5bead3a4b
19 changed files with 1007 additions and 33 deletions
@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {ChannelIntroRegistration} from 'types/store/plugins';
import ChannelIntroRenderer from './channel_intro_renderer';
const mockChannel = TestHelper.getChannelMock({id: 'channel-1'});
const makeRegistration = (component: React.ComponentType<any>): ChannelIntroRegistration => ({
id: 'reg-1',
pluginId: 'test-plugin',
matcher: () => true,
component,
});
const minimalState = {
entities: {
general: {config: {}},
preferences: {myPreferences: {}},
users: {currentUserId: 'user1', profiles: {}},
},
} as any;
describe('components/channel_intro_renderer/ChannelIntroRenderer', () => {
it('renders the plugin component with channel prop', () => {
const PluginComponent = () => <div data-testid='plugin-component'/>;
const reg = makeRegistration(PluginComponent);
renderWithContext(
<ChannelIntroRenderer
registration={reg}
channel={mockChannel}
/>,
minimalState,
);
expect(screen.getByTestId('plugin-component')).toBeInTheDocument();
});
it('error boundary catches a crashing component and host area stays mounted', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const Crasher = (): null => {
throw new Error('test crash');
};
const reg = makeRegistration(Crasher);
const {container} = renderWithContext(
<ChannelIntroRenderer
registration={reg}
channel={mockChannel}
/>,
minimalState,
);
expect(screen.getByText(/An error occurred/i)).toBeInTheDocument();
expect(screen.getByText(/Refresh/i)).toBeInTheDocument();
// Container is still mounted — the crash did not tear down the host DOM
expect(container).toBeInTheDocument();
consoleSpy.mockRestore();
});
});
@@ -0,0 +1,25 @@
// 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 PluggableErrorBoundary from 'plugins/pluggable/error_boundary';
import type {ChannelIntroRegistration} from 'types/store/plugins';
export default function ChannelIntroRenderer({registration, channel}: {
registration: ChannelIntroRegistration;
channel: Channel;
}) {
const Component = registration.component;
return (
<PluggableErrorBoundary
key={`${registration.id}:${channel.id}`}
pluginId={registration.pluginId}
>
<Component channel={channel}/>
</PluggableErrorBoundary>
);
}
@@ -5,24 +5,18 @@ import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs';
import type {Channel} from '@mattermost/types/channels';
import {getChannelIconClassName} from 'utils/channel_utils';
import {createMatcherErrorLog} from 'utils/matcher_error_log';
import type {GlobalState} from 'types/store';
// Tracks plugin ids that have already logged a matcher error to avoid spamming the console.
const loggedMatcherErrors = new Set<string>();
const matcherErrorLog = createMatcherErrorLog('ChannelIconOverride');
/**
* Clears the per-pluginId log-once tracker for matcher errors.
* No-arg form clears all entries; with-arg form clears one plugin's entry.
* Called on each new registration so that re-registering a plugin starts fresh.
*/
export function clearLoggedMatcherErrors(pluginId?: string): void {
if (pluginId === undefined) {
loggedMatcherErrors.clear();
} else {
loggedMatcherErrors.delete(pluginId);
}
}
export const clearLoggedMatcherErrors = matcherErrorLog.clear;
/**
* Returns the IconGlyphTypes name of the first matching plugin override, or null.
@@ -46,14 +40,7 @@ export function getChannelIconOverrideForChannel(
return entry.iconName;
}
} catch (err) {
if (!loggedMatcherErrors.has(entry.pluginId)) {
loggedMatcherErrors.add(entry.pluginId);
// eslint-disable-next-line no-console
console.error(
`ChannelIconOverride: matcher for plugin '${entry.pluginId}' threw — treating as no-match.`,
err,
);
}
matcherErrorLog.logOnce(entry.pluginId, err);
}
}
return null;
@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import {ChannelComposerBanner} from './channel_composer_banner';
const makeState = (channelId: string, components: any[] = []) => ({
entities: {
channels: {
channels: {
[channelId]: TestHelper.getChannelMock({id: channelId}),
},
},
},
plugins: {
components: {
ChannelComposerBanner: components,
},
},
} as any);
describe('components/channel_view/ChannelComposerBanner', () => {
const channelId = 'channel_id';
test('no registered component — renders nothing', () => {
const {container} = renderWithContext(
<ChannelComposerBanner channelId={channelId}/>,
makeState(channelId, []),
);
expect(container.firstChild).toBeNull();
});
test('registered component renders above the composer', () => {
const state = makeState(channelId, [{
id: 'banner-1',
pluginId: 'test-plugin',
component: () => <div data-testid='composer-banner-content'/>,
}]);
renderWithContext(
<ChannelComposerBanner channelId={channelId}/>,
state,
);
expect(screen.getByTestId('composer-banner-content')).toBeInTheDocument();
});
test('missing channel — renders nothing', () => {
const state = makeState(channelId, [{
id: 'banner-1',
pluginId: 'test-plugin',
component: () => <div data-testid='composer-banner-content'/>,
}]);
// Remove the channel to simulate missing channel entity
delete state.entities.channels.channels[channelId];
const {container} = renderWithContext(
<ChannelComposerBanner channelId={channelId}/>,
state,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByTestId('composer-banner-content')).not.toBeInTheDocument();
});
test('multiple registered components — all render in order', () => {
const state = makeState(channelId, [
{
id: 'banner-1',
pluginId: 'test-plugin',
component: () => <div data-testid='composer-banner-content-1'/>,
},
{
id: 'banner-2',
pluginId: 'test-plugin',
component: () => <div data-testid='composer-banner-content-2'/>,
},
]);
renderWithContext(
<ChannelComposerBanner channelId={channelId}/>,
state,
);
const first = screen.getByTestId('composer-banner-content-1');
const second = screen.getByTestId('composer-banner-content-2');
expect(first).toBeInTheDocument();
expect(second).toBeInTheDocument();
// First component precedes second in DOM order
expect(first.compareDocumentPosition(second)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
});
@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useSelector} from 'react-redux';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import Pluggable from 'plugins/pluggable';
import type {GlobalState} from 'types/store';
export const ChannelComposerBanner = ({channelId}: {channelId: string}) => {
const channel = useSelector((s: GlobalState) => getChannel(s, channelId));
if (!channel) {
return null;
}
return (
<Pluggable
pluggableName='ChannelComposerBanner'
channel={channel}
/>
);
};
@@ -14,6 +14,7 @@ import PostView from 'components/post_view';
import WebSocketClient from 'client/web_websocket_client';
import {ChannelComposerBanner} from './channel_composer_banner';
import InputLoading from './input_loading';
import type {PropsFromRedux} from './index';
@@ -206,6 +207,7 @@ export default class ChannelView extends React.PureComponent<Props, State> {
data-testid='post-create'
className='post-create__container AdvancedTextEditor__ctr'
>
<ChannelComposerBanner channelId={this.props.channelId}/>
<AdvancedCreatePost/>
</div>
);
@@ -294,6 +294,105 @@ describe('components/post_view/ChannelIntroMessages', () => {
});
});
describe('ChannelIntro body override', () => {
const privateChannel = {
...channel,
type: Constants.PRIVATE_CHANNEL as ChannelType,
};
const makeStateWithIntroReg = (regs: any[]) => ({
...initialState,
entities: {
...initialState.entities,
channels: {
...initialState.entities.channels,
channels: {channel_id: privateChannel},
},
},
plugins: {
components: {
ChannelIntro: regs,
},
},
} as any);
test('matching ChannelIntro registration — override body renders, action buttons still render', () => {
const state = makeStateWithIntroReg([{
id: 'intro-reg-1',
pluginId: 'test-plugin',
matcher: () => true,
component: () => <div data-testid='intro-override-body'/>,
}]);
renderWithContext(
<ChannelIntroMessage
{...baseProps}
channel={privateChannel}
/>,
state,
);
// Override body is present
expect(screen.getByTestId('intro-override-body')).toBeInTheDocument();
// Default body title is absent (it was replaced)
expect(screen.queryByText('test channel')).not.toBeInTheDocument();
// Action buttons row is still rendered by the server — Favorite button is always shown
expect(screen.getByLabelText('Favorite')).toBeInTheDocument();
});
test('no matching registration — default body renders', () => {
const state = makeStateWithIntroReg([{
id: 'intro-reg-1',
pluginId: 'test-plugin',
matcher: () => false,
component: () => <div data-testid='intro-override-body'/>,
}]);
renderWithContext(
<ChannelIntroMessage
{...baseProps}
channel={privateChannel}
/>,
state,
);
// Default body title is present
expect(screen.getByText('test channel')).toBeInTheDocument();
// Override body is absent
expect(screen.queryByTestId('intro-override-body')).not.toBeInTheDocument();
});
test('DM channel — ChannelIntro registration does not affect DM intro (non-standard channel)', () => {
const dmChannel = {
...channel,
type: Constants.DM_CHANNEL as ChannelType,
};
const state = makeStateWithIntroReg([{
id: 'intro-reg-1',
pluginId: 'test-plugin',
matcher: () => true,
component: () => <div data-testid='intro-override-body'/>,
}]);
renderWithContext(
<ChannelIntroMessage
{...baseProps}
channel={dmChannel}
/>,
state,
);
// DM intro renders its own layout — override body should not appear
expect(screen.queryByTestId('intro-override-body')).not.toBeInTheDocument();
// DM text is present
expect(screen.getByText('This is the start of your direct message history with this teammate.', {exact: false})).toBeInTheDocument();
});
});
describe('plugin channel icon override', () => {
const mockedCompassIconForName = jest.mocked(compassIconForName);
@@ -3,6 +3,7 @@
import React from 'react';
import {FormattedDate, FormattedMessage, defineMessages} from 'react-intl';
import {useSelector} from 'react-redux';
import {BellRingOutlineIcon, PencilOutlineIcon, StarOutlineIcon, StarIcon} from '@mattermost/compass-icons/components';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
@@ -13,7 +14,10 @@ import {Permissions} from 'mattermost-redux/constants';
import {NotificationLevel} from 'mattermost-redux/constants/channels';
import {isChannelMuted} from 'mattermost-redux/utils/channel_utils';
import {getChannelIntroOverride} from 'selectors/channel_intro';
import AddGroupsToTeamModal from 'components/add_groups_to_team_modal';
import ChannelIntroRenderer from 'components/channel_intro_renderer/channel_intro_renderer';
import ChannelNotificationsModal from 'components/channel_notifications_modal';
import {ChannelIcon} from 'components/channel_type_icon';
import ChannelIntroPrivateSvg from 'components/common/svg_images_components/channel_intro_private_svg';
@@ -30,6 +34,8 @@ import {Constants, ModalIdentifiers} from 'utils/constants';
import {getMonthLong} from 'utils/i18n';
import * as Utils from 'utils/utils';
import type {GlobalState} from 'types/store';
import AddMembersButton from './add_members_button';
import PluggableIntroButtons from './pluggable_intro_buttons';
@@ -524,6 +530,24 @@ function createDefaultIntroMessage(
);
}
/**
* Renders either a plugin-supplied intro body for channels the plugin's matcher selects, or the
* default children. Action buttons (favorite, add members, etc.) are rendered outside this
* component so the server controls them regardless of any override.
*/
const ChannelIntroBody = ({channel, children}: {channel: Channel; children: React.ReactNode}) => {
const override = useSelector((state: GlobalState) => getChannelIntroOverride(state, channel.id));
if (override) {
return (
<ChannelIntroRenderer
registration={override}
channel={channel}
/>
);
}
return <>{children}</>;
};
function createStandardIntroMessage(
channel: Channel,
centeredIntro: string,
@@ -684,21 +708,23 @@ function createStandardIntroMessage(
id='channelIntro'
className={'channel-intro ' + centeredIntro}
>
{isPrivate ? <ChannelIntroPrivateSvg/> : <ChannelIntroPublicSvg/>}
<h2 className='channel-intro__title'>
{channel.display_name}
</h2>
<div className='channel-intro__created'>
<ChannelIcon
channel={channel}
size={14}
/>
{createMessage}
</div>
<p className='channel-intro__text'>
{memberMessage}
{purposeMessage}
</p>
<ChannelIntroBody channel={channel}>
{isPrivate ? <ChannelIntroPrivateSvg/> : <ChannelIntroPublicSvg/>}
<h2 className='channel-intro__title'>
{channel.display_name}
</h2>
<div className='channel-intro__created'>
<ChannelIcon
channel={channel}
size={14}
/>
{createMessage}
</div>
<p className='channel-intro__text'>
{memberMessage}
{purposeMessage}
</p>
</ChannelIntroBody>
{actionButtons}
</div>
);
@@ -14,7 +14,7 @@ import type {emitShortcutReactToLastPostFrom} from 'actions/post_actions';
import CenterMessageLock from 'components/center_message_lock';
import PostComponent from 'components/post';
import ChannelIntroMessage from 'components/post_view/channel_intro_message/';
import ChannelIntroMessage from 'components/post_view/channel_intro_message';
import CombinedUserActivityPost from 'components/post_view/combined_user_activity_post';
import DateSeparator from 'components/post_view/date_separator';
import NewMessageSeparator from 'components/post_view/new_message_separator/new_message_separator';
@@ -139,4 +139,33 @@ describe('components/threading/CreateComment', () => {
expect(screen.getByTestId('advanced-create-comment')).toBeInTheDocument();
});
it('renders ChannelComposerBanner component above the thread composer', () => {
const channel = TestHelper.getChannelMock({
id: 'ch-1',
type: 'O',
delete_at: 0,
});
const state = {
...makeState(channel, threadId),
plugins: {
components: {
ChannelIconOverride: [],
ChannelComposerBanner: [{
id: 'banner-1',
pluginId: 'test-plugin',
component: () => <div data-testid='composer-banner-content'/>,
}],
},
},
} as any;
renderWithContext(
<CreateComment threadId={threadId}/>,
state,
);
expect(screen.getByTestId('composer-banner-content')).toBeInTheDocument();
});
});
@@ -12,6 +12,7 @@ import {getPost, getLimitedViews} from 'mattermost-redux/selectors/entities/post
import AdvancedCreateComment from 'components/advanced_create_comment';
import {compassIconForName, useChannelIconOverrideName} from 'components/channel_type_icon';
import {ChannelComposerBanner} from 'components/channel_view/channel_composer_banner';
import BasicSeparator from 'components/widgets/separator/basic-separator';
import {getArchiveIconComponent} from 'utils/channel_utils';
@@ -104,6 +105,7 @@ const CreateComment = forwardRef<HTMLDivElement, Props>(({
ref={ref}
data-testid='comment-create'
>
<ChannelComposerBanner channelId={channel.id}/>
<AdvancedCreateComment
placeholder={placeholder}
channelId={channel.id}
@@ -251,3 +251,83 @@ describe('PluginRegistry — registerChannelIconOverride', () => {
consoleSpy.mockRestore();
});
});
describe('PluginRegistry — registerChannelComposerBannerComponent', () => {
const PLUGIN_ID = 'test_plugin';
beforeEach(() => {
mockCurrentStore = createStore(pluginsReducer);
});
function getBanners() {
return mockCurrentStore.getState().components.ChannelComposerBanner;
}
it('adds an entry to ChannelComposerBanner with the plugin id', () => {
const registry = new PluginRegistry(PLUGIN_ID);
registry.registerChannelComposerBannerComponent({component: () => null});
const entries = getBanners();
expect(entries).toHaveLength(1);
expect(entries[0].pluginId).toBe(PLUGIN_ID);
});
it('REMOVED_WEBAPP_PLUGIN sweeps entries for that plugin and leaves others intact', () => {
const registry = new PluginRegistry(PLUGIN_ID);
const otherRegistry = new PluginRegistry('other_plugin');
registry.registerChannelComposerBannerComponent({component: () => null});
otherRegistry.registerChannelComposerBannerComponent({component: () => null});
mockCurrentStore.dispatch({
type: ActionTypes.REMOVED_WEBAPP_PLUGIN,
data: {id: PLUGIN_ID},
});
const entries = getBanners();
expect(entries).toHaveLength(1);
expect(entries[0].pluginId).toBe('other_plugin');
});
});
describe('PluginRegistry — registerChannelIntro', () => {
const PLUGIN_ID = 'test_plugin';
beforeEach(() => {
mockCurrentStore = createStore(pluginsReducer);
});
function getIntroRegs() {
return mockCurrentStore.getState().components.ChannelIntro;
}
it('adds an entry to ChannelIntro with the plugin id, matcher, and component', () => {
const registry = new PluginRegistry(PLUGIN_ID);
const matcher = () => true;
const component = () => null;
registry.registerChannelIntro({matcher, component});
const entries = getIntroRegs();
expect(entries).toHaveLength(1);
expect(entries[0].pluginId).toBe(PLUGIN_ID);
expect(entries[0].matcher).toBe(matcher);
expect(entries[0].component).toBe(component);
});
it('REMOVED_WEBAPP_PLUGIN sweeps entries for that plugin and leaves others intact', () => {
const registry = new PluginRegistry(PLUGIN_ID);
const otherRegistry = new PluginRegistry('other_plugin');
registry.registerChannelIntro({matcher: () => true, component: () => null});
otherRegistry.registerChannelIntro({matcher: () => false, component: () => null});
mockCurrentStore.dispatch({
type: ActionTypes.REMOVED_WEBAPP_PLUGIN,
data: {id: PLUGIN_ID},
});
const entries = getIntroRegs();
expect(entries).toHaveLength(1);
expect(entries[0].pluginId).toBe('other_plugin');
});
});
+30
View File
@@ -27,6 +27,7 @@ import {
registerPluginReconnectHandler,
unregisterPluginReconnectHandler,
} from 'actions/websocket_actions';
import {clearLoggedChannelIntroErrors} from 'selectors/channel_intro';
import store from 'stores/redux_store';
import {compassIconForName} from 'components/channel_type_icon';
@@ -73,6 +74,7 @@ import type {
AIActionMenuItemComponent,
ChannelTypeOptionComponent,
ChannelIconOverrideRegistration,
ChannelIntroRegistration,
} from 'types/store/plugins';
const defaultShouldRender = () => true;
@@ -1397,6 +1399,34 @@ export default class PluginRegistry {
return id;
});
/**
* Register a component rendered above the message input in both the center-channel composer
* and the thread/RHS composer. Receives {channel}; return null when nothing should show.
* Multiple registrations stack. Cleaned up automatically when the plugin is removed.
*/
registerChannelComposerBannerComponent = reArg(['component'], ({component}: DPluginComponentProp) => {
return dispatchPluginComponentAction('ChannelComposerBanner', this.id, component);
});
/**
* Register a component that replaces the descriptive body (icon, title, creation info, and
* description) of a standard public/private channel's intro for channels the matcher selects.
* The channel's action buttons (favorite, add members, set header, notification preferences,
* and plugin intro buttons) remain rendered by the server. The matcher receives the full Redux
* state so it can read plugin-owned slices (e.g. state['plugins-<id>']). First registration
* whose matcher returns true wins (alphabetical pluginId, then insertion order); the rest are
* ignored for that channel. Cleaned up automatically when the plugin is removed.
*/
registerChannelIntro = reArg(['matcher', 'component'], ({matcher, component}: {
matcher: ChannelIntroRegistration['matcher'];
component: ChannelIntroRegistration['component'];
}) => {
clearLoggedChannelIntroErrors(this.id);
const id = generateId();
dispatchPluginComponentWithData('ChannelIntro', {id, pluginId: this.id, matcher, component});
return id;
});
/**
* INTERNAL: Subject to change without notice.
* Register a component to render in channel's center view, in place of a channel toast.
@@ -224,6 +224,8 @@ const initialComponents: PluginsState['components'] = {
SidebarBrowseOrAddChannelMenu: [],
ChannelTypeOption: [],
ChannelIconOverride: [],
ChannelComposerBanner: [],
ChannelIntro: [],
MessageWillBePosted: [],
MessageWillBeUpdated: [],
SlashCommandWillBePosted: [],
@@ -0,0 +1,228 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel} from '@mattermost/types/channels';
import type {GlobalState} from 'types/store';
import type {ChannelIntroRegistration} from 'types/store/plugins';
import {clearLoggedChannelIntroErrors, getChannelIntroOverride} from './channel_intro';
function makeChannel(partial: Partial<Channel> = {}): Channel {
return {
id: 'channel-1',
type: 'O',
delete_at: 0,
...partial,
} as Channel;
}
function makeState(
regs: ChannelIntroRegistration[] = [],
channels: Record<string, Channel> = {},
): GlobalState {
return {
plugins: {
components: {
ChannelIntro: regs,
},
},
entities: {
channels: {
channels,
},
},
} as unknown as GlobalState;
}
function makeRegistration(partial: Partial<ChannelIntroRegistration> = {}): ChannelIntroRegistration {
return {
id: 'reg-1',
pluginId: 'test-plugin',
matcher: () => true,
component: () => null,
...partial,
} as ChannelIntroRegistration;
}
describe('selectors/getChannelIntroOverride', () => {
beforeEach(() => {
clearLoggedChannelIntroErrors();
});
describe('empty / missing data', () => {
it('returns null when ChannelIntro list is empty', () => {
const channel = makeChannel();
const state = makeState([], {[channel.id]: channel});
expect(getChannelIntroOverride(state, channel.id)).toBeNull();
});
it('returns null when channelId is empty string', () => {
const channel = makeChannel();
const state = makeState([makeRegistration()], {[channel.id]: channel});
expect(getChannelIntroOverride(state, '')).toBeNull();
});
it('returns null when channel is not in state', () => {
const state = makeState([makeRegistration()], {});
expect(getChannelIntroOverride(state, 'nonexistent')).toBeNull();
});
});
describe('matcher semantics — strict boolean true', () => {
it('returns the registration when matcher returns exactly true', () => {
const channel = makeChannel();
const reg = makeRegistration({matcher: () => true});
const state = makeState([reg], {[channel.id]: channel});
expect(getChannelIntroOverride(state, channel.id)).toBe(reg);
});
it('returns null when matcher returns false', () => {
const channel = makeChannel();
const reg = makeRegistration({matcher: () => false});
const state = makeState([reg], {[channel.id]: channel});
expect(getChannelIntroOverride(state, channel.id)).toBeNull();
});
it('returns null when matcher returns truthy non-boolean (channel object)', () => {
const channel = makeChannel();
const reg = makeRegistration({matcher: (_state, ch) => ch as unknown as boolean});
const state = makeState([reg], {[channel.id]: channel});
expect(getChannelIntroOverride(state, channel.id)).toBeNull();
});
it('returns null when matcher returns 1 (truthy non-boolean)', () => {
const channel = makeChannel();
const reg = makeRegistration({matcher: () => 1 as unknown as boolean});
const state = makeState([reg], {[channel.id]: channel});
expect(getChannelIntroOverride(state, channel.id)).toBeNull();
});
});
describe('first-match-wins semantics', () => {
it('returns the first matching registration when multiple registrations match', () => {
const channel = makeChannel();
const reg1 = makeRegistration({id: 'r1', pluginId: 'alpha'});
const reg2 = makeRegistration({id: 'r2', pluginId: 'beta'});
const state = makeState([reg1, reg2], {[channel.id]: channel});
const result = getChannelIntroOverride(state, channel.id);
expect(result).toBe(reg1);
});
it('returns the first alphabetical pluginId when reducer inserts them in order', () => {
const channel = makeChannel();
const regAlpha = makeRegistration({id: 'r1', pluginId: 'alpha'});
const regBeta = makeRegistration({id: 'r2', pluginId: 'beta'});
// Reducer inserts in alphabetical pluginId order, so alpha comes first
const state = makeState([regAlpha, regBeta], {[channel.id]: channel});
const result = getChannelIntroOverride(state, channel.id);
expect(result?.pluginId).toBe('alpha');
});
it('returns null when no registrations match', () => {
const channel = makeChannel();
const reg = makeRegistration({matcher: () => false});
const state = makeState([reg], {[channel.id]: channel});
expect(getChannelIntroOverride(state, channel.id)).toBeNull();
});
});
describe('matcher error handling', () => {
it('treats a throwing matcher as no-match', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const channel = makeChannel();
const reg = makeRegistration({
pluginId: 'bad-plugin',
matcher: () => {
throw new Error('boom');
},
});
const state = makeState([reg], {[channel.id]: channel});
expect(getChannelIntroOverride(state, channel.id)).toBeNull();
consoleSpy.mockRestore();
});
it('logs the error exactly once for a given pluginId', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const channel = makeChannel();
const reg = makeRegistration({
pluginId: 'bad-plugin',
matcher: () => {
throw new Error('boom');
},
});
const state = makeState([reg], {[channel.id]: channel});
getChannelIntroOverride(state, channel.id);
getChannelIntroOverride(state, channel.id);
getChannelIntroOverride(state, channel.id);
expect(consoleSpy).toHaveBeenCalledTimes(1);
consoleSpy.mockRestore();
});
});
describe('clearLoggedChannelIntroErrors', () => {
it('resets the error log so errors are logged again after clearing', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const channel = makeChannel();
const reg = makeRegistration({
pluginId: 'bad-plugin',
matcher: () => {
throw new Error('boom');
},
});
const state = makeState([reg], {[channel.id]: channel});
getChannelIntroOverride(state, channel.id);
expect(consoleSpy).toHaveBeenCalledTimes(1);
clearLoggedChannelIntroErrors('bad-plugin');
getChannelIntroOverride(state, channel.id);
expect(consoleSpy).toHaveBeenCalledTimes(2);
consoleSpy.mockRestore();
});
it('clears all entries when called with no argument', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const channel = makeChannel();
const reg = makeRegistration({
pluginId: 'bad-plugin',
matcher: () => {
throw new Error('boom');
},
});
const state = makeState([reg], {[channel.id]: channel});
getChannelIntroOverride(state, channel.id);
expect(consoleSpy).toHaveBeenCalledTimes(1);
clearLoggedChannelIntroErrors();
getChannelIntroOverride(state, channel.id);
expect(consoleSpy).toHaveBeenCalledTimes(2);
consoleSpy.mockRestore();
});
});
describe('matcher receives full state', () => {
it('passes full state to matcher so plugins can read their own slice', () => {
const channel = makeChannel();
const capturedStates: GlobalState[] = [];
const reg = makeRegistration({
matcher: (st) => {
capturedStates.push(st);
return true;
},
});
const state = makeState([reg], {[channel.id]: channel});
getChannelIntroOverride(state, channel.id);
expect(capturedStates).toHaveLength(1);
expect(capturedStates[0]).toBe(state);
});
});
});
@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createMatcherErrorLog} from 'utils/matcher_error_log';
import type {GlobalState} from 'types/store';
import type {ChannelIntroRegistration} from 'types/store/plugins';
const matcherErrorLog = createMatcherErrorLog('ChannelIntro');
export const clearLoggedChannelIntroErrors = matcherErrorLog.clear;
/** First registration whose matcher returns === true for this channel, or null. */
export function getChannelIntroOverride(
state: GlobalState,
channelId: string,
): ChannelIntroRegistration | null {
const regs = state.plugins.components.ChannelIntro;
if (!channelId || !regs?.length) {
return null;
}
const channel = state.entities?.channels?.channels?.[channelId];
if (!channel) {
return null;
}
for (const reg of regs) {
try {
if (reg.matcher(state, channel) === true) {
return reg;
}
} catch (err) {
matcherErrorLog.logOnce(reg.pluginId, err, 'intro');
}
}
return null;
}
@@ -72,6 +72,8 @@ export type PluginsState = {
SidebarBrowseOrAddChannelMenu: SidebarBrowseOrAddChannelMenuAction[];
ChannelTypeOption: ChannelTypeOptionComponent[];
ChannelIconOverride: ChannelIconOverrideRegistration[];
ChannelComposerBanner: ChannelComposerBannerComponent[];
ChannelIntro: ChannelIntroRegistration[];
FilesWillUploadHook: FilesWillUploadHook[];
DesktopNotificationHooks: DesktopNotificationHook[];
MessageWillFormat: MessageWillFormatHook[];
@@ -427,6 +429,15 @@ export type ChannelIconOverrideRegistration = PluginComponent & {
iconName: IconGlyphTypes;
};
export type ChannelComposerBannerComponent = PluginComponent & {
component: React.ComponentType<{channel: Channel}>;
};
export type ChannelIntroRegistration = PluginComponent & {
matcher: (state: GlobalState, channel: Channel) => boolean;
component: React.ComponentType<{channel: Channel}>;
};
export type ChannelTypeOptionComponent = PluginComponent & {
label: PluggableText;
description: PluggableText;
@@ -0,0 +1,182 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createMatcherErrorLog} from './matcher_error_log';
describe('utils/createMatcherErrorLog', () => {
let consoleSpy: jest.SpyInstance;
beforeEach(() => {
consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
describe('logOnce — no slot (icon-override keying scheme)', () => {
it('logs on first call for a pluginId', () => {
const {logOnce} = createMatcherErrorLog('TestLabel');
const err = new Error('boom');
logOnce('plugin-a', err);
expect(consoleSpy).toHaveBeenCalledTimes(1);
expect(consoleSpy.mock.calls[0][0]).toBe(
"TestLabel: matcher for plugin 'plugin-a' threw — treating as no-match.",
);
expect(consoleSpy.mock.calls[0][1]).toBe(err);
});
it('does not log on subsequent calls for the same pluginId', () => {
const {logOnce} = createMatcherErrorLog('TestLabel');
const err = new Error('boom');
logOnce('plugin-a', err);
logOnce('plugin-a', err);
logOnce('plugin-a', err);
expect(consoleSpy).toHaveBeenCalledTimes(1);
});
it('message has no " at slot" fragment when slot is absent', () => {
const {logOnce} = createMatcherErrorLog('TestLabel');
logOnce('plugin-a', new Error('boom'));
const msg: string = consoleSpy.mock.calls[0][0];
expect(msg).not.toContain('at slot');
});
it('logs once per distinct pluginId', () => {
const {logOnce} = createMatcherErrorLog('TestLabel');
logOnce('plugin-a', new Error('a'));
logOnce('plugin-b', new Error('b'));
logOnce('plugin-a', new Error('a2'));
expect(consoleSpy).toHaveBeenCalledTimes(2);
});
});
describe('logOnce — with slot (decorator keying scheme)', () => {
it('logs on first call for a pluginId+slot pair', () => {
const {logOnce} = createMatcherErrorLog('TestLabel');
const err = new Error('boom');
logOnce('plugin-a', err, 'intro');
expect(consoleSpy).toHaveBeenCalledTimes(1);
expect(consoleSpy.mock.calls[0][0]).toBe(
"TestLabel: matcher for plugin 'plugin-a' at slot 'intro' threw — treating as no-match.",
);
});
it('does not log on subsequent calls for the same pluginId+slot', () => {
const {logOnce} = createMatcherErrorLog('TestLabel');
logOnce('plugin-a', new Error('boom'), 'intro');
logOnce('plugin-a', new Error('boom'), 'intro');
expect(consoleSpy).toHaveBeenCalledTimes(1);
});
it('same pluginId but different slots produce distinct keys and each log once', () => {
const {logOnce} = createMatcherErrorLog('TestLabel');
logOnce('plugin-a', new Error('boom'), 'intro');
logOnce('plugin-a', new Error('boom'), 'other-slot');
expect(consoleSpy).toHaveBeenCalledTimes(2);
});
it('slot is included in the error message text', () => {
const {logOnce} = createMatcherErrorLog('TestLabel');
logOnce('plugin-a', new Error('boom'), 'some-slot');
const msg: string = consoleSpy.mock.calls[0][0];
expect(msg).toContain("at slot 'some-slot'");
});
});
describe('clear — with pluginId argument', () => {
it('clears the exact-pluginId key (icon scheme) so errors log again', () => {
const {logOnce, clear} = createMatcherErrorLog('TestLabel');
logOnce('plugin-a', new Error('boom'));
expect(consoleSpy).toHaveBeenCalledTimes(1);
clear('plugin-a');
logOnce('plugin-a', new Error('boom'));
expect(consoleSpy).toHaveBeenCalledTimes(2);
});
it('clears pluginId:-prefixed keys (decorator scheme) so errors log again', () => {
const {logOnce, clear} = createMatcherErrorLog('TestLabel');
logOnce('plugin-a', new Error('boom'), 'intro');
logOnce('plugin-a', new Error('boom'), 'above_composer');
expect(consoleSpy).toHaveBeenCalledTimes(2);
clear('plugin-a');
logOnce('plugin-a', new Error('boom'), 'intro');
logOnce('plugin-a', new Error('boom'), 'above_composer');
expect(consoleSpy).toHaveBeenCalledTimes(4);
});
it('does not clear keys belonging to other plugins', () => {
const {logOnce, clear} = createMatcherErrorLog('TestLabel');
logOnce('plugin-a', new Error('boom'), 'intro');
logOnce('plugin-b', new Error('boom'), 'intro');
expect(consoleSpy).toHaveBeenCalledTimes(2);
clear('plugin-a');
// plugin-b is still silenced
logOnce('plugin-b', new Error('boom'), 'intro');
expect(consoleSpy).toHaveBeenCalledTimes(2);
// plugin-a logs again
logOnce('plugin-a', new Error('boom'), 'intro');
expect(consoleSpy).toHaveBeenCalledTimes(3);
});
it('guards against prefix collisions (e.g. "foo" vs "foobar")', () => {
const {logOnce, clear} = createMatcherErrorLog('TestLabel');
// Both 'foo' and 'foobar' log under their own slots
logOnce('foo', new Error('boom'), 'intro');
logOnce('foobar', new Error('boom'), 'intro');
expect(consoleSpy).toHaveBeenCalledTimes(2);
// Clearing 'foo' must NOT clear 'foobar'
clear('foo');
logOnce('foobar', new Error('boom'), 'intro');
expect(consoleSpy).toHaveBeenCalledTimes(2); // foobar still silenced
logOnce('foo', new Error('boom'), 'intro');
expect(consoleSpy).toHaveBeenCalledTimes(3); // foo logs again
});
});
describe('clear — no argument', () => {
it('clears all entries so every key logs again', () => {
const {logOnce, clear} = createMatcherErrorLog('TestLabel');
logOnce('plugin-a', new Error('boom'));
logOnce('plugin-b', new Error('boom'), 'intro');
expect(consoleSpy).toHaveBeenCalledTimes(2);
clear();
logOnce('plugin-a', new Error('boom'));
logOnce('plugin-b', new Error('boom'), 'intro');
expect(consoleSpy).toHaveBeenCalledTimes(4);
});
});
});
@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Plugin-registered matchers run on the render path. A throwing matcher is treated as a no-match,
// but the first occurrence per key is logged so a broken plugin is diagnosable without spamming the
// console on every render. Callers key by pluginId alone, or by pluginId+slot when one label hosts
// several matcher slots — the optional `slot` selects the keying scheme.
export function createMatcherErrorLog(label: string) {
const logged = new Set<string>();
const logOnce = (pluginId: string, err: unknown, slot?: string): void => {
const key = slot === undefined ? pluginId : `${pluginId}:${slot}`;
if (logged.has(key)) {
return;
}
logged.add(key);
const where = slot === undefined ? '' : ` at slot '${slot}'`;
// eslint-disable-next-line no-console
console.error(
`${label}: matcher for plugin '${pluginId}'${where} threw — treating as no-match.`,
err,
);
};
// Clears all entries, or just those for one plugin: the exact-pluginId key plus any
// `${pluginId}:`-prefixed (slot-keyed) entries. The colon guards against prefix collisions
// between plugin ids (e.g. 'foo' vs 'foobar').
const clear = (pluginId?: string): void => {
if (pluginId === undefined) {
logged.clear();
return;
}
for (const key of logged) {
if (key === pluginId || key.startsWith(`${pluginId}:`)) {
logged.delete(key);
}
}
};
return {logOnce, clear};
}