From b530799aeb0393ac5c24ecb2f1d2664db688dbf1 Mon Sep 17 00:00:00 2001 From: Ben Schumacher Date: Mon, 13 Jul 2026 08:16:36 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 * Fix lint issues in plugin metadata panel files Co-authored-by: Ben Schumacher * Fix stylelint property order in plugin metadata scss Co-authored-by: Ben Schumacher * Add i18n string for plugin metadata release notes link Co-authored-by: Ben Schumacher * 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 * Simplify plugin ID styling and shrink copy icon Co-authored-by: Ben Schumacher * 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 * Update plugin management snapshots for muted metadata styling Co-authored-by: Ben Schumacher * 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 * 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 * Use shared useCopyText hook for plugin ID copy Co-authored-by: Ben Schumacher * Fix stylelint property order in plugin metadata panel styles Co-authored-by: Ben Schumacher * 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 --------- Co-authored-by: Cursor Agent Co-authored-by: Ben Schumacher --- .../custom_plugin_settings/index.test.tsx | 51 +++- .../custom_plugin_settings/index.ts | 2 + .../plugin_management.test.tsx.snap | 231 ++++++++++++++---- .../plugin_management/plugin_management.tsx | 22 +- .../plugin_metadata_panel.scss | 26 ++ .../plugin_metadata_panel.test.tsx | 91 +++++++ .../plugin_metadata_panel.tsx | 136 +++++++++++ .../admin_console/schema_admin_settings.tsx | 39 +++ 8 files changed, 537 insertions(+), 61 deletions(-) create mode 100644 webapp/channels/src/components/admin_console/plugin_metadata_panel/plugin_metadata_panel.scss create mode 100644 webapp/channels/src/components/admin_console/plugin_metadata_panel/plugin_metadata_panel.test.tsx create mode 100644 webapp/channels/src/components/admin_console/plugin_metadata_panel/plugin_metadata_panel.tsx diff --git a/webapp/channels/src/components/admin_console/custom_plugin_settings/index.test.tsx b/webapp/channels/src/components/admin_console/custom_plugin_settings/index.test.tsx index 5c4796eb719..34db6b1df30 100644 --- a/webapp/channels/src/components/admin_console/custom_plugin_settings/index.test.tsx +++ b/webapp/channels/src/components/admin_console/custom_plugin_settings/index.test.tsx @@ -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( { />, {...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( + } + 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(); diff --git a/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts b/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts index 2270f06ce59..72c36d817d1 100644 --- a/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts +++ b/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts @@ -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, }; }; } diff --git a/webapp/channels/src/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap b/webapp/channels/src/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap index cfc406a1705..af8f9da2f83 100644 --- a/webapp/channels/src/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap @@ -4207,16 +4207,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
-
+ Plugin 0 - ( - plugin_0 - - - 0.1.0 - ) -
+ +
@@ -4314,16 +4333,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
-
+ Plugin 1 - ( - plugin_1 - - - 0.0.1 - ) -
+ +
@@ -4866,16 +4904,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
-
+ Plugin 0 - ( - plugin_0 - - - 0.1.0 - ) -
+ +
@@ -5342,16 +5399,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
-
+ Plugin 0 - ( - plugin_0 - - - 0.1.0 - ) -
+ +
@@ -5818,16 +5894,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
-
+ Plugin 0 - ( - plugin_0 - - - 0.1.0 - ) -
+ +
@@ -6294,16 +6389,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
-
+ Plugin 0 - ( - plugin_0 - - - 0.1.0 - ) -
+ +
@@ -6393,16 +6507,35 @@ exports[`components/PluginManagement should match snapshot, with installed plugi
-
+ Plugin 1 - ( - plugin_1 - - - 0.0.1 - ) -
+ +
diff --git a/webapp/channels/src/components/admin_console/plugin_management/plugin_management.tsx b/webapp/channels/src/components/admin_console/plugin_management/plugin_management.tsx index abe691e01f6..1f39c4264ea 100644 --- a/webapp/channels/src/components/admin_console/plugin_management/plugin_management.tsx +++ b/webapp/channels/src/components/admin_console/plugin_management/plugin_management.tsx @@ -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 (
-
- {pluginStatus.name} - {' ('} - {pluginStatus.id} - {' - '} - {pluginStatus.version} - {')'} -
+ {description}
{activateButton} @@ -997,6 +1002,7 @@ export class PluginManagement extends OLDAdminSettings { { + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/plugin_metadata_panel/plugin_metadata_panel.tsx b/webapp/channels/src/components/admin_console/plugin_metadata_panel/plugin_metadata_panel.tsx new file mode 100644 index 00000000000..e677acf8f8b --- /dev/null +++ b/webapp/channels/src/components/admin_console/plugin_metadata_panel/plugin_metadata_panel.tsx @@ -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(() => ( + + ), [tooltipMessage]); + + return ( + + + {id} + + + ); +}; + +const PluginMetadataPanel = ({ + name, + id, + version, + homepageUrl, + releaseNotesUrl, + className, +}: PluginMetadataPanelProps) => { + const displayName = name.trim() || id; + const formattedVersion = formatPluginVersion(version); + + let nameElement: React.ReactNode = {displayName}; + if (homepageUrl) { + nameElement = ( + + {displayName} + + ); + } + + let versionElement: React.ReactNode = null; + if (formattedVersion) { + versionElement = ( + <> + {' - '} + {releaseNotesUrl ? ( + + {formattedVersion} + + ) : ( + + {formattedVersion} + + )} + + ); + } + + return ( + + {nameElement} + + {' ('} + + {versionElement} + {')'} + + + ); +}; + +export default PluginMetadataPanel; diff --git a/webapp/channels/src/components/admin_console/schema_admin_settings.tsx b/webapp/channels/src/components/admin_console/schema_admin_settings.tsx index 3eaf84684ea..dabe9ffbdee 100644 --- a/webapp/channels/src/components/admin_console/schema_admin_settings.tsx +++ b/webapp/channels/src/components/admin_console/schema_admin_settings.tsx @@ -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 + ); + + return ( +

+ {title} +

+ ); + } + const betaBadge = this.props.schema.isBeta && ( { + if (!this.props.plugin) { + return null; + } + + return ( +
+ +
+ ); + }; + renderBanner = (setting: AdminDefinitionSettingBanner) => { if (!this.props.schema || !('label' in setting)) { return {''}; @@ -1353,6 +1391,7 @@ export class SchemaAdminSettings extends React.PureComponent
+ {this.renderPluginMetadata()}