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()}