[MM-63635] Fix plugin RHS panels not opening from the App Bar in the Threads view (#38116)

* MM-63635 Allow plugin RHS panels to open from the App Bar in the Threads view

The Threads view dispatches selectLhsItem(Page), which clears the current
channel. The App Bar click handler only invoked a plugin's action when both a
channel and a channel membership were present, unless the component carried an
rhsComponentId. Plugins registered through registerChannelHeaderButtonAction
(Copilot among them) have no rhsComponentId, so their App Bar icon did nothing
at all outside of a channel.

Invoke the action regardless of channel context, and widen the action types to
reflect that the App Bar can call them without a channel.

Also exempt plugin RHS panels from the suppression that the Threads view
applies on mount, so an open plugin panel survives the switch instead of being
force-closed and left in a state where the next App Bar click toggles it off.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* MM-63635 Add tests for opening plugin RHS panels from the Threads view

Covers the App Bar invoking a plugin action with and without a channel in
context, for plugins registered through both registerChannelHeaderButtonAction
and registerAppBarComponent, asserting the RHS actually opens in the store.
Also covers which RHS states the Threads view suppresses on mount.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* MM-63635 Strengthen the plugin RHS tests after review

Assert the plugin action's channel arguments explicitly, add the case where the
App Bar is clicked while the RHS is suppressed, cover the Threads view mount
effect clearing the current channel, and add an end-to-end case that clicks the
App Bar icon while the Threads view is mounted.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* MM-63635 Use a realistic App Bar icon fixture in the plugin component tests

registerAppBarComponent always supplies an iconUrl, so the fixture rendered
markup production never produces. Give it a URL and assert the active state on
the markup each registration style actually renders.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* MM-63635 Trim narration comments from the plugin RHS change

Co-authored-by: mattermost-code <matty-code@mattermost.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: mattermost-code <matty-code@mattermost.com>
This commit is contained in:
cursor[bot]
2026-08-24 08:51:48 -07:00
committed by GitHub
co-authored by mattermost-code Cursor Agent
parent 9091791efe
commit 2ee9804f99
5 changed files with 382 additions and 7 deletions
@@ -0,0 +1,193 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Store} from 'redux';
import type {DeepPartial} from '@mattermost/types/utilities';
import {toggleRHSPlugin} from 'actions/views/rhs';
import {getIsRhsOpen, getPluggableId, getRhsState} from 'selectors/rhs';
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {RHSStates} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import type {AppBarAction, ChannelHeaderButtonAction, RightHandSidebarComponent} from 'types/store/plugins';
import AppBarPluginComponent from './app_bar_plugin_component';
describe('components/app_bar/app_bar_plugin_component', () => {
const pluginId = 'com.mattermost.test-plugin';
const rhsComponentId = 'the_rhs_component_id';
const channel = TestHelper.getChannelMock({id: 'channel1'});
const channelMember = TestHelper.getChannelMembershipMock({channel_id: 'channel1', user_id: 'user1'});
const rhsComponents: RightHandSidebarComponent[] = [{
id: rhsComponentId,
pluginId,
component: () => null,
title: 'Test Plugin',
}];
const inChannelState = {
entities: {
channels: {
currentChannelId: channel.id,
channels: {[channel.id]: channel},
myMembers: {[channel.id]: channelMember},
},
users: {
currentUserId: 'user1',
},
},
views: {
rhs: {
pluggableId: '',
},
},
plugins: {
components: {
RightHandSidebarComponent: rhsComponents,
},
},
};
// The Threads and Drafts views clear the current channel.
const noChannelState = mergeObjects(inChannelState, {
entities: {
channels: {
currentChannelId: '',
},
},
});
// Arriving in the Threads view from a channel showing pinned posts leaves the RHS suppressed.
const suppressedRhsState = mergeObjects(noChannelState, {
views: {
rhs: {
isSidebarOpen: true,
rhsState: RHSStates.PIN,
},
rhsSuppressed: true,
},
});
let store: Store;
const channelHeaderButton: ChannelHeaderButtonAction = {
id: 'the_channel_header_button_id',
pluginId,
icon: <i className='icon icon-test'/>,
dropdownText: 'Test Plugin',
tooltipText: 'Test Plugin',
action: jest.fn(() => {
store.dispatch(toggleRHSPlugin(rhsComponentId));
}),
};
const appBarActionWithRhs: AppBarAction = {
id: 'the_app_bar_action_id',
pluginId,
iconUrl: 'http://localhost:8065/plugins/com.mattermost.test-plugin/public/icon.svg',
supportedProductIds: null,
tooltipText: 'Test Plugin',
rhsComponentId,
action: jest.fn(() => {
store.dispatch(toggleRHSPlugin(rhsComponentId));
return {data: true};
}),
};
const renderAppBarIcon = (component: ChannelHeaderButtonAction | AppBarAction, state: DeepPartial<GlobalState>) => {
const rendered = renderWithContext(
<AppBarPluginComponent component={component}/>,
state,
);
store = rendered.store;
return rendered;
};
const expectRhsToBeOpen = () => {
expect(getIsRhsOpen(store.getState())).toBe(true);
expect(getRhsState(store.getState())).toBe(RHSStates.PLUGIN);
expect(getPluggableId(store.getState())).toBe(rhsComponentId);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('plugin registered through registerChannelHeaderButtonAction', () => {
test('should open the plugin RHS when clicked while viewing a channel', async () => {
renderAppBarIcon(channelHeaderButton, inChannelState);
await userEvent.click(screen.getByRole('button'));
expectRhsToBeOpen();
expect(channelHeaderButton.action).toHaveBeenCalledWith(channel, channelMember);
});
test('should open the plugin RHS when clicked with no channel in context', async () => {
renderAppBarIcon(channelHeaderButton, noChannelState);
await userEvent.click(screen.getByRole('button'));
expectRhsToBeOpen();
expect(channelHeaderButton.action).toHaveBeenCalledWith(undefined, undefined);
});
test('should open the plugin RHS when clicked while the RHS is suppressed', async () => {
renderAppBarIcon(channelHeaderButton, suppressedRhsState);
expect(getIsRhsOpen(store.getState())).toBe(false);
await userEvent.click(screen.getByRole('button'));
expectRhsToBeOpen();
});
test('should highlight the icon while its RHS is open and stop highlighting it once closed', async () => {
renderAppBarIcon(channelHeaderButton, noChannelState);
expect(screen.getByRole('button')).not.toHaveClass('app-bar__old-icon--active');
await userEvent.click(screen.getByRole('button'));
expectRhsToBeOpen();
expect(screen.getByRole('button')).toHaveClass('app-bar__old-icon--active');
await userEvent.click(screen.getByRole('button'));
expect(getIsRhsOpen(store.getState())).toBe(false);
expect(getPluggableId(store.getState())).toBe('');
expect(screen.getByRole('button')).not.toHaveClass('app-bar__old-icon--active');
});
});
describe('plugin registered through registerAppBarComponent', () => {
test('should open the plugin RHS when clicked while viewing a channel', async () => {
renderAppBarIcon(appBarActionWithRhs, inChannelState);
await userEvent.click(screen.getByRole('button'));
expectRhsToBeOpen();
expect(screen.getByRole('button').closest('.app-bar__icon')).toHaveClass('app-bar__icon--active');
// An action with an rhsComponentId is called without any channel context.
expect(appBarActionWithRhs.action).toHaveBeenCalledWith();
});
test('should open the plugin RHS when clicked with no channel in context', async () => {
renderAppBarIcon(appBarActionWithRhs, noChannelState);
await userEvent.click(screen.getByRole('button'));
expectRhsToBeOpen();
expect(appBarActionWithRhs.action).toHaveBeenCalledWith();
});
});
});
@@ -108,13 +108,12 @@ const AppBarPluginComponent = ({
id={buttonId}
className={classNames('app-bar__icon', {'app-bar__icon--active': isButtonActive})}
onClick={() => {
if (channel && channelMember) {
component.action?.(channel, channelMember);
return;
}
if ('rhsComponentId' in component) {
component.action();
return;
}
component.action?.(channel, channelMember);
}}
>
{content}
@@ -0,0 +1,180 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Store} from 'redux';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common';
import {toggleRHSPlugin} from 'actions/views/rhs';
import {getIsRhsOpen, getPluggableId, getRhsState} from 'selectors/rhs';
import AppBarPluginComponent from 'components/app_bar/app_bar_plugin_component';
import {renderWithContext, runPostRenderAct, screen, userEvent} from 'tests/react_testing_utils';
import {RHSStates} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import {LhsPage} from 'types/store/lhs';
import type {ChannelHeaderButtonAction, RightHandSidebarComponent} from 'types/store/plugins';
import GlobalThreads from './global_threads';
// Only the network-backed thunks are stubbed; the RHS suppression logic under test is real.
jest.mock('mattermost-redux/actions/threads', () => ({
...jest.requireActual('mattermost-redux/actions/threads'),
getThreadCounts: jest.fn(() => ({type: 'MOCK_GET_THREAD_COUNTS'})),
getThreadsForCurrentTeam: jest.fn(() => ({type: 'MOCK_GET_THREADS_FOR_CURRENT_TEAM'})),
}));
jest.mock('actions/user_actions', () => ({
...jest.requireActual('actions/user_actions'),
loadProfilesForSidebar: jest.fn(),
}));
describe('components/threading/global_threads', () => {
const pluginId = 'com.mattermost.test-plugin';
const rhsComponentId = 'the_rhs_component_id';
const channel = TestHelper.getChannelMock({id: 'channel1'});
const rhsComponents: RightHandSidebarComponent[] = [{
id: rhsComponentId,
pluginId,
component: () => null,
title: 'Test Plugin',
}];
const baseState = {
entities: {
general: {
config: {},
},
channels: {
currentChannelId: channel.id,
channels: {[channel.id]: channel},
myMembers: {[channel.id]: TestHelper.getChannelMembershipMock({channel_id: channel.id, user_id: 'user1'})},
},
teams: {
currentTeamId: 'team1',
teams: {team1: TestHelper.getTeamMock({id: 'team1', name: 'team1'})},
},
users: {
currentUserId: 'user1',
profiles: {user1: TestHelper.getUserMock({id: 'user1'})},
},
preferences: {
myPreferences: {},
},
posts: {
posts: {},
},
},
views: {
rhs: {
isSidebarOpen: true,
},
rhsSuppressed: false,
},
plugins: {
components: {
RightHandSidebarComponent: rhsComponents,
},
},
};
const stateWithRhsState = (rhsState: string) => ({
...baseState,
views: {
...baseState.views,
rhs: {
...baseState.views.rhs,
rhsState,
pluggableId: rhsState === RHSStates.PLUGIN ? rhsComponentId : '',
},
},
});
let store: Store;
const renderGlobalThreads = async (rhsState: string, children?: React.ReactNode) => {
const rendered = renderWithContext(
<>
<GlobalThreads/>
{children}
</>,
stateWithRhsState(rhsState),
);
store = rendered.store;
await runPostRenderAct();
return rendered;
};
test('should select the Threads page and clear the current channel on mount', async () => {
await renderGlobalThreads(RHSStates.PLUGIN);
expect(store.getState().views.lhs.currentStaticPageId).toBe(LhsPage.Threads);
expect(getCurrentChannelId(store.getState())).toBe('');
});
test.each([
RHSStates.PLUGIN,
RHSStates.MENTION,
RHSStates.SEARCH,
RHSStates.FLAG,
])('should leave the RHS open on mount when it shows %s', async (rhsState) => {
await renderGlobalThreads(rhsState);
expect(store.getState().views.rhsSuppressed).toBe(false);
expect(getIsRhsOpen(store.getState())).toBe(true);
});
test.each([
RHSStates.PIN,
RHSStates.CHANNEL_INFO,
RHSStates.CHANNEL_FILES,
RHSStates.CHANNEL_MEMBERS,
RHSStates.EDIT_HISTORY,
])('should suppress the RHS on mount when it shows %s', async (rhsState) => {
await renderGlobalThreads(rhsState);
expect(store.getState().views.rhsSuppressed).toBe(true);
expect(getIsRhsOpen(store.getState())).toBe(false);
});
test('should unsuppress the RHS when navigating away', async () => {
const {unmount} = await renderGlobalThreads(RHSStates.PIN);
expect(getIsRhsOpen(store.getState())).toBe(false);
unmount();
expect(getIsRhsOpen(store.getState())).toBe(true);
});
test('should open a plugin RHS from the App Bar while showing the Threads view', async () => {
const channelHeaderButton: ChannelHeaderButtonAction = {
id: 'the_channel_header_button_id',
pluginId,
icon: <i className='icon icon-test'/>,
dropdownText: 'Test Plugin',
tooltipText: 'Test Plugin',
action: () => store.dispatch(toggleRHSPlugin(rhsComponentId)),
};
await renderGlobalThreads(
RHSStates.PIN,
<AppBarPluginComponent component={channelHeaderButton}/>,
);
expect(getIsRhsOpen(store.getState())).toBe(false);
await userEvent.click(screen.getByRole('button'));
expect(getIsRhsOpen(store.getState())).toBe(true);
expect(getRhsState(store.getState())).toBe(RHSStates.PLUGIN);
expect(getPluggableId(store.getState())).toBe(rhsComponentId);
});
});
@@ -67,7 +67,8 @@ const GlobalThreads = () => {
if (!(
rhsState === RHSStates.MENTION ||
rhsState === RHSStates.SEARCH ||
rhsState === RHSStates.FLAG)
rhsState === RHSStates.FLAG ||
rhsState === RHSStates.PLUGIN)
) {
dispatch(suppressRHS);
}
+4 -2
View File
@@ -148,7 +148,9 @@ type BasePluggableProps = {
export type PluggableText = string | React.ReactNode;
export type AppBarChannelAction = (channel: Channel, member: ChannelMembership) => void;
// The App Bar is rendered outside of channels too, such as in Threads and Drafts,
// so these actions can run with no channel in context.
export type AppBarChannelAction = (channel?: Channel, member?: ChannelMembership) => void;
export type AppBarAction = PluginComponent & {
iconUrl: string;
supportedProductIds: ProductScope;
@@ -184,7 +186,7 @@ export type ChannelHeaderButtonAction = PluginComponent & {
icon: React.ReactNode;
dropdownText: PluggableText;
tooltipText: PluggableText;
action: (channel: Channel, member?: ChannelMembership) => void;
action: AppBarChannelAction;
};
export type ChannelHeaderIconComponent = PluginComponent & {