Add plugin metadata panel to management and settings pages (#37331)

* Add PluginMetadataPanel to plugin management and settings pages

Surface plugin ID, version, website, and release notes links on the
Plugin Management list and individual plugin settings pages so admins
do not need to switch between pages to find plugin identity information.

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Simplify plugin metadata to a single inline line

Show plugin identity as 'Name (id - version - website)' on Plugin
Management and in the plugin settings page header instead of a
multi-row metadata block.

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Refine plugin metadata line with code ID, name link, and v prefix

Style the plugin ID as inline code with a copy button, link the display
name to the website URL, and prefix versions with v when missing.

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Move plugin metadata into settings page content area

Render the metadata line in the plugin settings content body instead of
the oversized admin header so it matches Plugin Management styling.
Use the installed plugin status version when available.

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Fix lint issues in plugin metadata panel files

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Fix stylelint property order in plugin metadata scss

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Add i18n string for plugin metadata release notes link

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Harden plugin metadata edge cases and link version to release notes

Fall back to the plugin ID when the display name is empty, omit the
version segment when no version is installed, and link the version text
to release notes instead of showing a separate release notes label.

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Simplify plugin ID styling and shrink copy icon

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Style plugin metadata parenthetical as muted technical text

Wrap the ID and version in a smaller, muted segment and use monospace
for the plugin ID without code-block highlighting.

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Update plugin management snapshots for muted metadata styling

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Add visually hidden page heading for plugin settings pages

Plugin settings pages hide the AdminHeader title in favor of the inline
metadata panel, which left no accessible page heading. Render an sr-only h1
with the schema title so assistive tech retains page semantics without
changing the visual layout.

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Make plugin ID text clickable to copy

Replace the separate copy icon with click-to-copy on the monospace plugin
ID. The ID shows a pointer cursor and link color on hover, with the same
Copy text / Copied tooltip feedback as before.

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Use shared useCopyText hook for plugin ID copy

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Fix stylelint property order in plugin metadata panel styles

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

* Address review: add name/id test and memoize copy handlers

Add an integration test where plugin display name differs from plugin ID.
Use useCallback and useMemo in PluginMetadataId to avoid recreating
handlers and tooltip content on every render.

Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Ben Schumacher <hanzei@users.noreply.github.com>
This commit is contained in:
Ben Schumacher
2026-07-13 08:16:36 +02:00
committed by GitHub
co-authored by Ben Schumacher Cursor Agent
parent cce485f605
commit b530799aeb
8 changed files with 537 additions and 61 deletions
@@ -82,6 +82,13 @@ describe('custom plugin sections and settings', () => {
},
};
const expectPluginPageTitle = (pluginName: string, pluginId: string) => {
const panel = screen.getByTestId('plugin-metadata-panel');
expect(panel).toHaveTextContent(`${pluginName} (${pluginId}`);
expect(document.querySelector('.PluginMetadataPanel__settingsWrapper')).toContainElement(panel);
expect(screen.getByRole('heading', {level: 1, hidden: true})).toHaveTextContent(pluginName);
};
it('empty sections and settings', () => {
renderWithContext(
<CustomPluginSettings
@@ -90,12 +97,48 @@ describe('custom plugin sections and settings', () => {
/>,
{...baseState});
expect(screen.getByText('testplugin')).toBeInTheDocument();
expectPluginPageTitle('testplugin', 'testplugin');
expect(screen.getByTestId('PluginSettings.PluginStates.testplugin.Enable')).toBeInTheDocument();
expect(screen.getByText('This is the header')).toBeInTheDocument();
expect(screen.getByText('This is the footer')).toBeInTheDocument();
});
it('renders plugin metadata with distinct display name and id', () => {
const pluginId = 'com.mattermost.fl3xx';
const pluginName = 'FL3XX';
const namedPlugin = {
...plugin,
id: pluginId,
name: pluginName,
};
renderWithContext(
<CustomPluginSettings
{...baseProps}
match={{params: {plugin_id: pluginId}} as match<{plugin_id: string}>}
config={{
PluginSettings: {
Plugins: {
[pluginId]: {},
},
} as unknown as PluginSettings,
}}
patchConfig={jest.fn()}
/>,
{
entities: {
admin: {
plugins: {
[pluginId]: namedPlugin,
},
},
},
},
);
expectPluginPageTitle(pluginName, pluginId);
});
it('all custom sections with plugin disabled should show single warning', () => {
const state = {
...baseState,
@@ -160,7 +203,7 @@ describe('custom plugin sections and settings', () => {
/>,
{...state});
expect(screen.getByText('testplugin')).toBeInTheDocument();
expectPluginPageTitle('testplugin', 'testplugin');
expect(screen.getByTestId('PluginSettings.PluginStates.testplugin.Enable')).toBeInTheDocument();
expect(screen.getByText('In order to view and configure plugin settings, enable the plugin and click Save.')).toBeInTheDocument();
expect(screen.queryByText('Custom Section 1')).not.toBeInTheDocument();
@@ -239,7 +282,7 @@ describe('custom plugin sections and settings', () => {
/>,
{...state});
expect(screen.getByText('testplugin')).toBeInTheDocument();
expectPluginPageTitle('testplugin', 'testplugin');
expect(screen.getByTestId('PluginSettings.PluginStates.testplugin.Enable')).toBeInTheDocument();
expect(screen.queryByText('In order to view and configure plugin settings, enable the plugin and click Save.')).not.toBeInTheDocument();
expect(screen.queryByText('Custom Section 1')).toBeInTheDocument();
@@ -342,7 +385,7 @@ describe('custom plugin sections and settings', () => {
/>,
{...state});
expect(screen.getByText('testplugin')).toBeInTheDocument();
expectPluginPageTitle('testplugin', 'testplugin');
expect(screen.getByTestId('PluginSettings.PluginStates.testplugin.Enable')).toBeInTheDocument();
expect(screen.queryByText('In order to view and configure plugin settings, enable the plugin and click Save.')).not.toBeInTheDocument();
expect(screen.getByText('Custom Component Section 1')).toBeInTheDocument();
@@ -203,6 +203,8 @@ function makeMapStateToProps() {
return {
schema: getPluginSchema(state, pluginId),
roles: getRoles(state),
plugin: state.entities.admin.plugins?.[pluginId],
pluginVersion: state.entities.admin.pluginStatuses?.[pluginId]?.version,
};
};
}
@@ -4207,16 +4207,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
<div
data-testid="plugin_0"
>
<div>
<span
class="PluginMetadataPanel"
data-testid="plugin-metadata-panel"
>
<strong>
Plugin 0
</strong>
(
plugin_0
-
0.1.0
)
</div>
<span
class="PluginMetadataPanel__metadata"
>
(
<span
aria-label="Copy"
class="PluginMetadataPanel__id"
data-testid="plugin-metadata-id"
role="button"
tabindex="0"
>
plugin_0
</span>
-
<span
data-testid="plugin-metadata-version"
>
v0.1.0
</span>
)
</span>
</span>
<div
class="pt-2"
>
@@ -4314,16 +4333,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
<div
data-testid="plugin_1"
>
<div>
<span
class="PluginMetadataPanel"
data-testid="plugin-metadata-panel"
>
<strong>
Plugin 1
</strong>
(
plugin_1
-
0.0.1
)
</div>
<span
class="PluginMetadataPanel__metadata"
>
(
<span
aria-label="Copy"
class="PluginMetadataPanel__id"
data-testid="plugin-metadata-id"
role="button"
tabindex="0"
>
plugin_1
</span>
-
<span
data-testid="plugin-metadata-version"
>
v0.0.1
</span>
)
</span>
</span>
<div
class="pt-2"
>
@@ -4866,16 +4904,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
<div
data-testid="plugin_0"
>
<div>
<span
class="PluginMetadataPanel"
data-testid="plugin-metadata-panel"
>
<strong>
Plugin 0
</strong>
(
plugin_0
-
0.1.0
)
</div>
<span
class="PluginMetadataPanel__metadata"
>
(
<span
aria-label="Copy"
class="PluginMetadataPanel__id"
data-testid="plugin-metadata-id"
role="button"
tabindex="0"
>
plugin_0
</span>
-
<span
data-testid="plugin-metadata-version"
>
v0.1.0
</span>
)
</span>
</span>
<div
class="pt-2"
>
@@ -5342,16 +5399,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
<div
data-testid="plugin_0"
>
<div>
<span
class="PluginMetadataPanel"
data-testid="plugin-metadata-panel"
>
<strong>
Plugin 0
</strong>
(
plugin_0
-
0.1.0
)
</div>
<span
class="PluginMetadataPanel__metadata"
>
(
<span
aria-label="Copy"
class="PluginMetadataPanel__id"
data-testid="plugin-metadata-id"
role="button"
tabindex="0"
>
plugin_0
</span>
-
<span
data-testid="plugin-metadata-version"
>
v0.1.0
</span>
)
</span>
</span>
<div
class="pt-2"
>
@@ -5818,16 +5894,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
<div
data-testid="plugin_0"
>
<div>
<span
class="PluginMetadataPanel"
data-testid="plugin-metadata-panel"
>
<strong>
Plugin 0
</strong>
(
plugin_0
-
0.1.0
)
</div>
<span
class="PluginMetadataPanel__metadata"
>
(
<span
aria-label="Copy"
class="PluginMetadataPanel__id"
data-testid="plugin-metadata-id"
role="button"
tabindex="0"
>
plugin_0
</span>
-
<span
data-testid="plugin-metadata-version"
>
v0.1.0
</span>
)
</span>
</span>
<div
class="pt-2"
>
@@ -6294,16 +6389,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
<div
data-testid="plugin_0"
>
<div>
<span
class="PluginMetadataPanel"
data-testid="plugin-metadata-panel"
>
<strong>
Plugin 0
</strong>
(
plugin_0
-
0.1.0
)
</div>
<span
class="PluginMetadataPanel__metadata"
>
(
<span
aria-label="Copy"
class="PluginMetadataPanel__id"
data-testid="plugin-metadata-id"
role="button"
tabindex="0"
>
plugin_0
</span>
-
<span
data-testid="plugin-metadata-version"
>
v0.1.0
</span>
)
</span>
</span>
<div
class="pt-2"
>
@@ -6393,16 +6507,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
<div
data-testid="plugin_1"
>
<div>
<span
class="PluginMetadataPanel"
data-testid="plugin-metadata-panel"
>
<strong>
Plugin 1
</strong>
(
plugin_1
-
0.0.1
)
</div>
<span
class="PluginMetadataPanel__metadata"
>
(
<span
aria-label="Copy"
class="PluginMetadataPanel__id"
data-testid="plugin-metadata-id"
role="button"
tabindex="0"
>
plugin_1
</span>
-
<span
data-testid="plugin-metadata-version"
>
v0.0.1
</span>
)
</span>
</span>
<div
class="pt-2"
>
@@ -24,6 +24,7 @@ import * as Utils from 'utils/utils';
import BooleanSetting from '../boolean_setting';
import OLDAdminSettings from '../old_admin_settings';
import type {BaseProps, BaseState} from '../old_admin_settings';
import PluginMetadataPanel from '../plugin_metadata_panel/plugin_metadata_panel';
import SettingSet from '../setting_set';
import SettingsGroup from '../settings_group';
import TextSetting from '../text_setting';
@@ -178,6 +179,10 @@ type PluginStatus = {
type PluginItemProps = {
pluginStatus: PluginStatus;
plugin?: {
homepage_url?: string;
release_notes_url?: string;
};
removing: boolean;
handleEnable: (e: any) => any;
handleDisable: (e: any) => any;
@@ -228,6 +233,7 @@ export const searchableStrings = [
const PluginItem = ({
pluginStatus,
plugin,
removing,
handleEnable,
handleDisable,
@@ -430,14 +436,13 @@ const PluginItem = ({
return (
<div data-testid={pluginStatus.id}>
<div>
<strong>{pluginStatus.name}</strong>
{' ('}
{pluginStatus.id}
{' - '}
{pluginStatus.version}
{')'}
</div>
<PluginMetadataPanel
name={pluginStatus.name}
id={pluginStatus.id}
version={pluginStatus.version}
homepageUrl={plugin?.homepage_url}
releaseNotesUrl={plugin?.release_notes_url}
/>
{description}
<div className='pt-2'>
{activateButton}
@@ -997,6 +1002,7 @@ export class PluginManagement extends OLDAdminSettings<Props, State> {
<PluginItem
key={pluginStatus.id}
pluginStatus={pluginStatus}
plugin={p}
removing={this.state.removing === pluginStatus.id}
handleEnable={this.handleEnable}
handleDisable={this.handleDisable}
@@ -0,0 +1,26 @@
.PluginMetadataPanel {
font-size: inherit;
line-height: inherit;
&__settingsWrapper {
margin-bottom: 16px;
}
&__metadata {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 0.9em;
a {
color: var(--link-color);
}
}
&__id {
cursor: pointer;
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
&:hover {
color: var(--link-color);
}
}
}
@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PluginMetadataPanel, {formatPluginVersion} from 'components/admin_console/plugin_metadata_panel/plugin_metadata_panel';
import {screen, renderWithContext} from 'tests/react_testing_utils';
describe('formatPluginVersion', () => {
test('should prefix version with v when missing', () => {
expect(formatPluginVersion('0.7.4')).toBe('v0.7.4');
expect(formatPluginVersion('1.2.3')).toBe('v1.2.3');
});
test('should not duplicate v prefix', () => {
expect(formatPluginVersion('v0.7.4')).toBe('v0.7.4');
expect(formatPluginVersion('V1.0.0')).toBe('V1.0.0');
});
});
describe('PluginMetadataPanel', () => {
test('should render plugin name, id, and version on one line', () => {
renderWithContext(
<PluginMetadataPanel
name='FL3XX'
id='com.mattermost.fl3xx'
version='0.7.4'
/>,
);
expect(screen.getByTestId('plugin-metadata-panel')).toHaveTextContent('FL3XX (com.mattermost.fl3xx - v0.7.4)');
expect(screen.getByTestId('plugin-metadata-id')).toHaveTextContent('com.mattermost.fl3xx');
expect(screen.getByTestId('plugin-metadata-version')).toHaveTextContent('v0.7.4');
expect(screen.getByRole('button', {name: 'Copy'})).toHaveTextContent('com.mattermost.fl3xx');
});
test('should fall back to plugin id when display name is missing', () => {
renderWithContext(
<PluginMetadataPanel
name=' '
id='com.mattermost.fl3xx'
version='0.7.4'
/>,
);
expect(screen.getByTestId('plugin-metadata-panel')).toHaveTextContent('com.mattermost.fl3xx (com.mattermost.fl3xx - v0.7.4)');
expect(screen.getByText('com.mattermost.fl3xx', {selector: 'strong'})).toBeInTheDocument();
});
test('should omit version segment when version is missing', () => {
renderWithContext(
<PluginMetadataPanel
name='FL3XX'
id='com.mattermost.fl3xx'
version=''
/>,
);
expect(screen.getByTestId('plugin-metadata-panel')).toHaveTextContent('FL3XX (com.mattermost.fl3xx)');
expect(screen.queryByTestId('plugin-metadata-version')).not.toBeInTheDocument();
});
test('should link display name to website and version to release notes when provided', () => {
renderWithContext(
<PluginMetadataPanel
name='Agents Plugin'
id='com.mattermost.ai'
version='1.2.3'
homepageUrl='https://github.com/mattermost/mattermost-plugin-ai'
releaseNotesUrl='https://github.com/mattermost/mattermost-plugin-ai/releases/tag/v1.2.3'
/>,
);
expect(screen.getByRole('link', {name: 'Agents Plugin'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-plugin-ai');
expect(screen.getByRole('link', {name: 'v1.2.3'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-plugin-ai/releases/tag/v1.2.3');
expect(screen.queryByText('release notes')).not.toBeInTheDocument();
});
test('should not render links when urls are not provided', () => {
renderWithContext(
<PluginMetadataPanel
name='Agents Plugin'
id='com.mattermost.ai'
version='1.2.3'
/>,
);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useCallback, useMemo} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
import useCopyText, {messages as copyMessages} from 'components/common/hooks/useCopyText';
import ExternalLink from 'components/external_link';
import './plugin_metadata_panel.scss';
export function formatPluginVersion(version: string): string {
if (!version) {
return version;
}
return (/^v/i).test(version) ? version : `v${version}`;
}
export type PluginMetadataPanelProps = {
name: string;
id: string;
version: string;
homepageUrl?: string;
releaseNotesUrl?: string;
className?: string;
};
const PluginMetadataId = ({id}: {id: string}) => {
const intl = useIntl();
const {copiedRecently, onClick: copyId} = useCopyText({
text: id,
successCopyTimeout: 2000,
});
const tooltipMessage = copiedRecently ? copyMessages.copied : copyMessages.copy;
const handleCopy = useCallback((e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault();
copyId();
}, [copyId]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
handleCopy(e);
}
}, [handleCopy]);
const tooltipTitle = useMemo(() => (
<FormattedMessage {...tooltipMessage}/>
), [tooltipMessage]);
return (
<WithTooltip
title={tooltipTitle}
>
<span
className='PluginMetadataPanel__id'
data-testid='plugin-metadata-id'
onClick={handleCopy}
onKeyDown={handleKeyDown}
role='button'
tabIndex={0}
aria-label={intl.formatMessage(tooltipMessage)}
>
{id}
</span>
</WithTooltip>
);
};
const PluginMetadataPanel = ({
name,
id,
version,
homepageUrl,
releaseNotesUrl,
className,
}: PluginMetadataPanelProps) => {
const displayName = name.trim() || id;
const formattedVersion = formatPluginVersion(version);
let nameElement: React.ReactNode = <strong>{displayName}</strong>;
if (homepageUrl) {
nameElement = (
<ExternalLink
href={homepageUrl}
location='plugin_metadata_panel'
>
<strong>{displayName}</strong>
</ExternalLink>
);
}
let versionElement: React.ReactNode = null;
if (formattedVersion) {
versionElement = (
<>
{' - '}
{releaseNotesUrl ? (
<ExternalLink
href={releaseNotesUrl}
location='plugin_metadata_panel'
data-testid='plugin-metadata-version'
>
{formattedVersion}
</ExternalLink>
) : (
<span data-testid='plugin-metadata-version'>
{formattedVersion}
</span>
)}
</>
);
}
return (
<span
className={classNames('PluginMetadataPanel', className)}
data-testid='plugin-metadata-panel'
>
{nameElement}
<span className='PluginMetadataPanel__metadata'>
{' ('}
<PluginMetadataId id={id}/>
{versionElement}
{')'}
</span>
</span>
);
};
export default PluginMetadataPanel;
@@ -9,6 +9,7 @@ import {Link} from 'react-router-dom';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
import type {CloudState} from '@mattermost/types/cloud';
import type {AdminConfig, ClientLicense, EnvironmentConfig} from '@mattermost/types/config';
import type {PluginRedux} from '@mattermost/types/plugins';
import type {Role} from '@mattermost/types/roles';
import type {DeepPartial} from '@mattermost/types/utilities';
@@ -40,6 +41,7 @@ import * as I18n from 'i18n/i18n';
import Constants from 'utils/constants';
import {mappingValueFromRoles, rolesFromMapping} from 'utils/policy_roles_adapter';
import PluginMetadataPanel from './plugin_metadata_panel/plugin_metadata_panel';
import Setting from './setting';
import type {AdminDefinitionConfigSchemaSection, AdminDefinitionSetting, AdminDefinitionSettingBanner, AdminDefinitionSettingDropdownOption, AdminDefinitionSubSectionSchema, ConsoleAccess} from './types';
@@ -80,6 +82,8 @@ export type SchemaAdminSettingsProps = {
cloud: CloudState;
isCurrentUserSystemAdmin: boolean;
enterpriseReady: boolean;
plugin?: PluginRedux;
pluginVersion?: string;
} & WrappedComponentProps;
type State = {
@@ -307,6 +311,22 @@ export class SchemaAdminSettings extends React.PureComponent<SchemaAdminSettings
name = this.props.schema.name;
}
if (this.props.plugin) {
const title = typeof name === 'string' ? (
name
) : (
<FormattedMessage
{...name}
/>
);
return (
<h1 className='sr-only'>
{title}
</h1>
);
}
const betaBadge = this.props.schema.isBeta && (
<BetaTag
variant='default'
@@ -334,6 +354,24 @@ export class SchemaAdminSettings extends React.PureComponent<SchemaAdminSettings
);
};
renderPluginMetadata = () => {
if (!this.props.plugin) {
return null;
}
return (
<div className='PluginMetadataPanel__settingsWrapper'>
<PluginMetadataPanel
name={this.props.plugin.name}
id={this.props.plugin.id}
version={this.props.pluginVersion || this.props.plugin.version}
homepageUrl={this.props.plugin.homepage_url}
releaseNotesUrl={this.props.plugin.release_notes_url}
/>
</div>
);
};
renderBanner = (setting: AdminDefinitionSettingBanner) => {
if (!this.props.schema || !('label' in setting)) {
return <span>{''}</span>;
@@ -1353,6 +1391,7 @@ export class SchemaAdminSettings extends React.PureComponent<SchemaAdminSettings
{this.renderTitle()}
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
{this.renderPluginMetadata()}
<form
className='form-horizontal'
role='form'