mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-19 02:06:37 +08:00
[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:
co-authored by
Claude Opus 4.7
parent
0a738be909
commit
c5bead3a4b
@@ -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>
|
||||
);
|
||||
|
||||
+99
@@ -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);
|
||||
|
||||
|
||||
+41
-15
@@ -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';
|
||||
|
||||
+29
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
Reference in New Issue
Block a user