@@ -2632,55 +2248,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
@@ -2973,55 +2541,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
+ />
diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_menu/sidebar_channel_menu.test.tsx b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_menu/sidebar_channel_menu.test.tsx
index 5a85aafe3c1..b29506d8653 100644
--- a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_menu/sidebar_channel_menu.test.tsx
+++ b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_menu/sidebar_channel_menu.test.tsx
@@ -7,7 +7,7 @@ import type {ChannelType} from '@mattermost/types/channels';
import {CategoryTypes} from 'mattermost-redux/constants/channel_categories';
-import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
+import {renderWithContext, screen, userEvent, waitFor, within} from 'tests/react_testing_utils';
import Constants from 'utils/constants';
import {canPopout, isChannelPopoutWindow} from 'utils/popouts/popout_windows';
import {TestHelper} from 'utils/test_helper';
@@ -67,6 +67,18 @@ describe('components/sidebar/sidebar_channel/sidebar_channel_menu', () => {
const menuButton = screen.getByRole('button', {name: /channel options/i});
await user.click(menuButton);
await screen.findByRole('menu', {name: 'Edit channel menu'});
+
+ // Tooltip-on-hover is opened by `userEvent.click` before the click reaches
+ // the button; the menu opening then dismisses it via a fade-out transition.
+ // Wait for the tooltip portal child to fully unmount so snapshots are
+ // deterministic and don't capture the in-flight fade-out (which is
+ // sensitive to timer scheduling and CI load).
+ const portal = document.getElementById('root-portal');
+ if (portal) {
+ await waitFor(() => {
+ expect(within(portal).queryByRole('tooltip', {hidden: true})).not.toBeInTheDocument();
+ });
+ }
};
test('should match snapshot and contain correct buttons', async () => {
diff --git a/webapp/channels/src/utils/format_attribute_name.test.ts b/webapp/channels/src/utils/format_attribute_name.test.ts
new file mode 100644
index 00000000000..aa696f80b5a
--- /dev/null
+++ b/webapp/channels/src/utils/format_attribute_name.test.ts
@@ -0,0 +1,26 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {formatAttributeName} from './format_attribute_name';
+
+describe('formatAttributeName', () => {
+ test('snake_case to title case', () => {
+ expect(formatAttributeName('user_role')).toBe('User Role');
+ });
+
+ test('camelCase to title case', () => {
+ expect(formatAttributeName('userRole')).toBe('User Role');
+ });
+
+ test('leading uppercase does not leave a leading space in output', () => {
+ expect(formatAttributeName('Program')).toBe('Program');
+ });
+
+ test('preserves acronym runs before a capitalized word', () => {
+ expect(formatAttributeName('ABACPolicy')).toBe('Abac Policy');
+ });
+
+ test('preserves trailing acronym after camelCase prefix', () => {
+ expect(formatAttributeName('userID')).toBe('User Id');
+ });
+});
diff --git a/webapp/channels/src/utils/format_attribute_name.ts b/webapp/channels/src/utils/format_attribute_name.ts
new file mode 100644
index 00000000000..bea254f4aae
--- /dev/null
+++ b/webapp/channels/src/utils/format_attribute_name.ts
@@ -0,0 +1,18 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+/**
+ * Convert snake_case or camelCase attribute keys to Title Case with spaces
+ * (e.g. "user_role" → "User Role"). Splits camelCase on word boundaries only:
+ * lowercase→uppercase and acronym runs followed by a capitalized word, so
+ * acronyms like "ABACPolicy" or "userID" are not split per letter.
+ * trim() removes accidental leading/trailing spaces from replacements.
+ */
+export function formatAttributeName(name: string): string {
+ return name.
+ replace(/_/g, ' ').
+ replace(/([a-z])([A-Z])/g, '$1 $2').
+ replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').
+ replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase()).
+ trim();
+}