[MM-68459] Implement dictionary style end user indicators for membership policies (#36240)

This commit is contained in:
Ibrahim Serdar Acikgoz
2026-04-28 16:05:31 +02:00
committed by GitHub
parent 2283b51b0e
commit 5c43e4b15f
13 changed files with 237 additions and 626 deletions
+5 -1
View File
@@ -2910,7 +2910,11 @@ func getChannelAccessControlAttributes(c *Context, w http.ResponseWriter, r *htt
return
}
attributes, err := c.App.GetAccessControlPolicyAttributes(c.AppContext, c.Params.ChannelId, "*")
// Channel banners care about the membership rule — the attributes that
// determine who can be in the channel. Since the v0.3 migration stores the
// action as "membership" rather than "*", ask for it explicitly; the
// wildcard fallback in GetRule still covers older policies that kept "*".
attributes, err := c.App.GetAccessControlPolicyAttributes(c.AppContext, c.Params.ChannelId, model.AccessControlPolicyActionMembership)
if err != nil {
c.Err = err
return
@@ -40,6 +40,11 @@
&__policy-banner {
.TagGroup {
margin-top: 12px;
// Tighten vertical spacing when tags wrap onto multiple rows
// (TagGroup defaults to gap: 8px which is too airy in this
// banner). Column spacing is preserved.
row-gap: 0;
}
}
}
@@ -502,9 +502,9 @@ describe('components/channel_invite_modal', () => {
// Modal renders in a portal, so query from document instead of container
expect(document.querySelector('.AlertBanner')).not.toBeNull();
// Check that the attribute tags are shown
expect(screen.getByText('tag1')).toBeInTheDocument();
expect(screen.getByText('tag2')).toBeInTheDocument();
// Check that the attribute tags are shown in "Attribute: value" form.
expect(screen.getByText('Attribute1: tag1')).toBeInTheDocument();
expect(screen.getByText('Attribute1: tag2')).toBeInTheDocument();
});
test('should not show AlertBanner when policy_enforced is false', () => {
@@ -33,6 +33,7 @@ import GuestTag from 'components/widgets/tag/guest_tag';
import TagGroup from 'components/widgets/tag/tag_group';
import Constants, {ModalIdentifiers} from 'utils/constants';
import {formatAttributeName} from 'utils/format_attribute_name';
import {sortUsersAndGroups} from 'utils/utils';
import GroupOption from './group_option';
@@ -112,14 +113,29 @@ const ChannelInviteModalComponent = (props: Props) => {
props.channel.policy_enforced,
);
// Helper function to format attribute names for tooltips
const formatAttributeName = (name: string): string => {
// Convert snake_case or camelCase to Title Case with spaces
return name.
replace(/_/g, ' ').
replace(/([A-Z])/g, ' $1').
replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase());
};
// Memoise the rendered access-control tags so they don't re-render on
// every keystroke in the invite text box.
const accessControlTags = useMemo(() => {
if (structuredAttributes.length === 0) {
return null;
}
return (
<TagGroup>
{structuredAttributes.flatMap((attribute) =>
attribute.values.map((value) => {
const attributeLabel = formatAttributeName(attribute.name);
return (
<AlertTag
key={`${attribute.name}-${value}`}
tooltipTitle={attributeLabel}
text={`${attributeLabel}: ${value}`}
/>
);
}),
)}
</TagGroup>
);
}, [structuredAttributes]);
// Helper function to add a user or group to the selected list
const addValue = useCallback((value: UserProfileValue | GroupValue) => {
@@ -667,19 +683,7 @@ const ChannelInviteModalComponent = (props: Props) => {
/>
)}
>
{structuredAttributes.length > 0 && (
<TagGroup>
{structuredAttributes.flatMap((attribute) =>
attribute.values.map((value) => (
<AlertTag
key={`${attribute.name}-${value}`}
tooltipTitle={formatAttributeName(attribute.name)}
text={value}
/>
)),
)}
</TagGroup>
)}
{accessControlTags}
</AlertBanner>
</div>
)}
@@ -34,6 +34,11 @@
.TagGroup {
margin-top: 12px;
// Tighten vertical spacing when tags wrap onto multiple rows
// (TagGroup defaults to gap: 8px which is too airy in this
// banner). Column spacing is preserved.
row-gap: 0;
}
}
}
@@ -225,7 +225,9 @@ describe('channel_members_rhs/channel_members_rhs', () => {
);
expect(screen.getByText('Channel access is restricted by user attributes')).toBeInTheDocument();
expect(screen.getByText('tag1')).toBeInTheDocument();
expect(screen.getByText('tag2')).toBeInTheDocument();
// Each tag is rendered as "Attribute: value" for readability.
expect(screen.getByText('Attribute1: tag1')).toBeInTheDocument();
expect(screen.getByText('Attribute1: tag2')).toBeInTheDocument();
});
});
@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import debounce from 'lodash/debounce';
import React, {useCallback, useEffect, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useHistory} from 'react-router-dom';
@@ -20,6 +20,7 @@ import AlertTag from 'components/widgets/tag/alert_tag';
import TagGroup from 'components/widgets/tag/tag_group';
import Constants, {ModalIdentifiers} from 'utils/constants';
import {formatAttributeName} from 'utils/format_attribute_name';
import type {ModalData} from 'types/actions';
@@ -84,14 +85,29 @@ export default function ChannelMembersRHS({
channel.policy_enforced,
);
// Helper function to format attribute names for tooltips
const formatAttributeName = (name: string): string => {
// Convert snake_case or camelCase to Title Case with spaces
return name.
replace(/_/g, ' ').
replace(/([A-Z])/g, ' $1').
replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase());
};
// Memoise the rendered access-control tags so they don't re-render on
// every unrelated state change in the centre channel.
const accessControlTags = useMemo(() => {
if (structuredAttributes.length === 0) {
return null;
}
return (
<TagGroup>
{structuredAttributes.flatMap((attribute) =>
attribute.values.map((value) => {
const attributeLabel = formatAttributeName(attribute.name);
return (
<AlertTag
key={`${attribute.name}-${value}`}
tooltipTitle={attributeLabel}
text={`${attributeLabel}: ${value}`}
/>
);
}),
)}
</TagGroup>
);
}, [structuredAttributes]);
const searching = searchTerms !== '';
@@ -245,19 +261,7 @@ export default function ChannelMembersRHS({
defaultMessage: 'Channel access is restricted by user attributes',
})}
>
{structuredAttributes.length > 0 && (
<TagGroup>
{structuredAttributes.flatMap((attribute) =>
attribute.values.map((value) => (
<AlertTag
key={`${attribute.name}-${value}`}
tooltipTitle={formatAttributeName(attribute.name)}
text={value}
/>
)),
)}
</TagGroup>
)}
{accessControlTags}
{loading && <span className='loading-indicator'>{'Loading...'}</span>}
</AlertBanner>
</div>
@@ -7,7 +7,7 @@ import {Provider} from 'react-redux';
import configureStore from 'redux-mock-store';
import {thunk} from 'redux-thunk';
import {useAccessControlAttributes, EntityType} from './useAccessControlAttributes';
import {invalidateAccessControlAttributesCache, useAccessControlAttributes, EntityType} from './useAccessControlAttributes';
// Mock the getChannelAccessControlAttributes action
jest.mock('mattermost-redux/actions/channels', () => {
@@ -202,4 +202,31 @@ describe('useAccessControlAttributes', () => {
// The action should have been called again
expect(getChannelAccessControlAttributes).toHaveBeenCalledWith('channel-1');
});
test('invalidateAccessControlAttributesCache forces a refresh on next read', async () => {
// Prime the cache with a first fetch.
const {result: result1} = renderHook(() => useAccessControlAttributes(EntityType.Channel, 'channel-1', true), {wrapper});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(result1.current.structuredAttributes).toEqual([
{name: 'department', values: ['engineering', 'marketing']},
{name: 'location', values: ['remote']},
]);
const getChannelAccessControlAttributes = require('mattermost-redux/actions/channels').getChannelAccessControlAttributes;
getChannelAccessControlAttributes.mockClear();
// After invalidating the cache the next mount should hit the action again.
invalidateAccessControlAttributesCache(EntityType.Channel, 'channel-1');
const {result: result2} = renderHook(() => useAccessControlAttributes(EntityType.Channel, 'channel-1', true), {wrapper});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(getChannelAccessControlAttributes).toHaveBeenCalledWith('channel-1');
expect(result2.current.structuredAttributes).toEqual(result1.current.structuredAttributes);
});
});
@@ -15,22 +15,74 @@ export enum EntityType {
// more entity types will be added here in the future
}
// Module-level cache for access control attributes
// The cache stores processed data with a timestamp to implement a TTL (time-to-live)
// ProcessedAttributes is the shape that components consume: a structured
// dictionary of attribute -> values (used for the policy banners) plus a flat
// tag array (kept for backwards compatibility with consumers that only need
// the values without their owning attribute).
type ProcessedAttributes = {
attributeTags: string[];
structuredAttributes: AccessControlAttribute[];
};
// Module-level cache for access control attributes. The cache stores already
// processed data with a timestamp to implement a short TTL.
const attributesCache: Record<string, {
processedData: {
attributeTags: string[];
structuredAttributes: AccessControlAttribute[];
};
processedData: ProcessedAttributes;
timestamp: number;
}> = {};
// Cache TTL in milliseconds (5 minutes)
// Cache TTL in milliseconds (5 minutes).
const CACHE_TTL = 5 * 60 * 1000;
// Array of supported entity types for validation
const SUPPORTED_ENTITY_TYPES = Object.values(EntityType);
const EMPTY_PROCESSED: ProcessedAttributes = {
attributeTags: [],
structuredAttributes: [],
};
// processAttributeData converts the server response (a dictionary keyed by
// attribute name with arrays of literal values) into the structured form
// consumed by the UI. The dictionary preserves both the attribute name and
// the values associated with it so each tag can be displayed alongside its
// originating attribute.
function processAttributeData(data: Record<string, string[]> | undefined | null): ProcessedAttributes {
if (!data) {
return EMPTY_PROCESSED;
}
const attributeTags: string[] = [];
const structuredAttributes: AccessControlAttribute[] = [];
// Format: { "attributeName": ["value1", "value2"], "anotherAttribute": ["value3"] }
for (const [name, values] of Object.entries(data)) {
if (!Array.isArray(values)) {
continue;
}
structuredAttributes.push({name, values: [...values]});
for (const value of values) {
if (value !== undefined && value !== null) {
attributeTags.push(value);
}
}
}
return {attributeTags, structuredAttributes};
}
/**
* Invalidates the cached attributes for a single entity. Components that
* mutate the underlying access policy should call this to ensure the next
* read fetches fresh data instead of serving up to CACHE_TTL milliseconds of
* stale state.
*/
export function invalidateAccessControlAttributesCache(entityType: EntityType, entityId: string): void {
delete attributesCache[`${entityType}:${entityId}`];
}
/**
* A hook for fetching access control attributes for an entity
*
@@ -50,35 +102,9 @@ export const useAccessControlAttributes = (
const [error, setError] = useState<Error | null>(null);
const dispatch = useDispatch();
// Helper function to process attribute data and extract tags
const processAttributeData = useCallback((data: Record<string, string[]> | undefined) => {
if (!data) {
setAttributeTags([]);
setStructuredAttributes([]);
return;
}
const tags: string[] = [];
const attributes: AccessControlAttribute[] = [];
// Extract values from all properties in the response
// Format: { "attributeName": ["value1", "value2"], "anotherAttribute": ["value3"] }
Object.entries(data).forEach(([name, values]) => {
// Add to structured format
if (Array.isArray(values)) {
attributes.push({name, values: [...values]});
// Add to flat tags (existing behavior)
values.forEach((value) => {
if (value !== undefined && value !== null) {
tags.push(value);
}
});
}
});
setAttributeTags(tags);
setStructuredAttributes(attributes);
const applyProcessed = useCallback((processed: ProcessedAttributes) => {
setAttributeTags(processed.attributeTags);
setStructuredAttributes(processed.structuredAttributes);
}, []);
const fetchAttributes = useCallback(async (forceRefresh = false) => {
@@ -86,32 +112,25 @@ export const useAccessControlAttributes = (
return;
}
// Set loading state at the beginning
setLoading(true);
setError(null);
try {
// Validate entity type first
if (!SUPPORTED_ENTITY_TYPES.includes(entityType)) {
throw new Error(`Unsupported entity type: ${entityType}`);
}
// Check cache first (unless forceRefresh is true)
const cacheKey = `${entityType}:${entityId}`;
const cachedEntry = attributesCache[cacheKey];
const now = Date.now();
// Use cache if it exists and is not too old and forceRefresh is false
// But still set loading to false to trigger a state update for tests
// Serve from cache when fresh and the caller didn't force a refresh.
if (!forceRefresh && cachedEntry && (now - cachedEntry.timestamp < CACHE_TTL)) {
// Use the cached processed data directly instead of reprocessing
setAttributeTags(cachedEntry.processedData.attributeTags);
setStructuredAttributes(cachedEntry.processedData.structuredAttributes);
applyProcessed(cachedEntry.processedData);
setLoading(false);
return;
}
// Handle different entity types
let result;
switch (entityType) {
case EntityType.Channel:
@@ -122,56 +141,21 @@ export const useAccessControlAttributes = (
throw new Error(`Unsupported entity type: ${entityType}`);
}
// Check for error in the result
if (result.error) {
throw result.error;
}
const data = result.data;
// Process the data and store it in cache
if (data) {
const processedTags: string[] = [];
const processedAttributes: AccessControlAttribute[] = [];
// Process the data once
Object.entries(data).forEach(([name, values]) => {
if (Array.isArray(values)) {
processedAttributes.push({name, values: [...values]});
values.forEach((value) => {
if (value !== undefined && value !== null) {
processedTags.push(value);
}
});
}
});
// Store only the processed data
attributesCache[cacheKey] = {
processedData: {
attributeTags: processedTags,
structuredAttributes: processedAttributes,
},
timestamp: now,
};
// Set state
setAttributeTags(processedTags);
setStructuredAttributes(processedAttributes);
} else {
// Handle the case where data is undefined or null
setAttributeTags([]);
setStructuredAttributes([]);
}
const processed = processAttributeData(result.data);
attributesCache[cacheKey] = {processedData: processed, timestamp: now};
applyProcessed(processed);
} catch (err) {
setError(err as Error);
setAttributeTags([]);
setStructuredAttributes([]);
applyProcessed(EMPTY_PROCESSED);
} finally {
setLoading(false);
}
}, [entityType, entityId, hasAccessControl, processAttributeData]);
}, [entityType, entityId, hasAccessControl, dispatch, applyProcessed]);
// Fetch attributes when the component mounts or when dependencies change
useEffect(() => {
fetchAttributes();
}, [fetchAttributes]);
@@ -32,55 +32,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should match sn
<div
aria-hidden="true"
id="root-portal"
>
<div
data-floating-ui-portal=""
id=":r3:"
>
<div
class="tooltipContainer hidden-xs"
role="tooltip"
style="position: absolute; left: 0px; top: 0px; opacity: 0; transform: translate(-8px, 0px); transition-property: opacity; transition-duration: 150ms;"
tabindex="-1"
>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Channel options
</span>
</span>
</div>
<svg
aria-hidden="true"
height="10"
style="position: absolute; pointer-events: none; top: 0px; left: calc(100% - 0px); transform: rotate(-90deg);"
viewBox="0 0 10 10"
width="10"
>
<path
d="M0,0 H10 L5,6 Q5,6 5,6 Z"
stroke="none"
/>
<clippath
id=":r4:"
>
<rect
height="10"
width="10"
x="0"
y="0"
/>
</clippath>
</svg>
</div>
</div>
</div>
/>
<div
class="MuiModal-root-jIscme ljbrzK MuiPopover-root-kLIimo MuiPopover-root a11y__popup menu_menuStyled MuiModal-root"
role="presentation"
@@ -348,55 +300,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should match sn
<div
aria-hidden="true"
id="root-portal"
>
<div
data-floating-ui-portal=""
id=":r1n:"
>
<div
class="tooltipContainer hidden-xs"
role="tooltip"
style="position: absolute; left: 0px; top: 0px; opacity: 0; transform: translate(-8px, 0px); transition-property: opacity; transition-duration: 150ms;"
tabindex="-1"
>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Channel options
</span>
</span>
</div>
<svg
aria-hidden="true"
height="10"
style="position: absolute; pointer-events: none; top: 0px; left: calc(100% - 0px); transform: rotate(-90deg);"
viewBox="0 0 10 10"
width="10"
>
<path
d="M0,0 H10 L5,6 Q5,6 5,6 Z"
stroke="none"
/>
<clippath
id=":r1o:"
>
<rect
height="10"
width="10"
x="0"
y="0"
/>
</clippath>
</svg>
</div>
</div>
</div>
/>
<div
aria-hidden="true"
>
@@ -689,55 +593,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should match sn
<div
aria-hidden="true"
id="root-portal"
>
<div
data-floating-ui-portal=""
id=":r1i:"
>
<div
class="tooltipContainer hidden-xs"
role="tooltip"
style="position: absolute; left: 0px; top: 0px; opacity: 0; transform: translate(-8px, 0px); transition-property: opacity; transition-duration: 150ms;"
tabindex="-1"
>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Channel options
</span>
</span>
</div>
<svg
aria-hidden="true"
height="10"
style="position: absolute; pointer-events: none; top: 0px; left: calc(100% - 0px); transform: rotate(-90deg);"
viewBox="0 0 10 10"
width="10"
>
<path
d="M0,0 H10 L5,6 Q5,6 5,6 Z"
stroke="none"
/>
<clippath
id=":r1j:"
>
<rect
height="10"
width="10"
x="0"
y="0"
/>
</clippath>
</svg>
</div>
</div>
</div>
/>
<div
aria-hidden="true"
>
@@ -1030,55 +886,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
<div
aria-hidden="true"
id="root-portal"
>
<div
data-floating-ui-portal=""
id=":r11:"
>
<div
class="tooltipContainer hidden-xs"
role="tooltip"
style="position: absolute; left: 0px; top: 0px; opacity: 0; transform: translate(-8px, 0px); transition-property: opacity; transition-duration: 150ms;"
tabindex="-1"
>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Channel options
</span>
</span>
</div>
<svg
aria-hidden="true"
height="10"
style="position: absolute; pointer-events: none; top: 0px; left: calc(100% - 0px); transform: rotate(-90deg);"
viewBox="0 0 10 10"
width="10"
>
<path
d="M0,0 H10 L5,6 Q5,6 5,6 Z"
stroke="none"
/>
<clippath
id=":r12:"
>
<rect
height="10"
width="10"
x="0"
y="0"
/>
</clippath>
</svg>
</div>
</div>
</div>
/>
<div
aria-hidden="true"
>
@@ -1303,55 +1111,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
<div
aria-hidden="true"
id="root-portal"
>
<div
data-floating-ui-portal=""
id=":r16:"
>
<div
class="tooltipContainer hidden-xs"
role="tooltip"
style="position: absolute; left: 0px; top: 0px; opacity: 0; transform: translate(-8px, 0px); transition-property: opacity; transition-duration: 150ms;"
tabindex="-1"
>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Channel options
</span>
</span>
</div>
<svg
aria-hidden="true"
height="10"
style="position: absolute; pointer-events: none; top: 0px; left: calc(100% - 0px); transform: rotate(-90deg);"
viewBox="0 0 10 10"
width="10"
>
<path
d="M0,0 H10 L5,6 Q5,6 5,6 Z"
stroke="none"
/>
<clippath
id=":r17:"
>
<rect
height="10"
width="10"
x="0"
y="0"
/>
</clippath>
</svg>
</div>
</div>
</div>
/>
<div
aria-hidden="true"
>
@@ -1612,55 +1372,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
<div
aria-hidden="true"
id="root-portal"
>
<div
data-floating-ui-portal=""
id=":ri:"
>
<div
class="tooltipContainer hidden-xs"
role="tooltip"
style="position: absolute; left: 0px; top: 0px; opacity: 0; transform: translate(-8px, 0px); transition-property: opacity; transition-duration: 150ms;"
tabindex="-1"
>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Channel options
</span>
</span>
</div>
<svg
aria-hidden="true"
height="10"
style="position: absolute; pointer-events: none; top: 0px; left: calc(100% - 0px); transform: rotate(-90deg);"
viewBox="0 0 10 10"
width="10"
>
<path
d="M0,0 H10 L5,6 Q5,6 5,6 Z"
stroke="none"
/>
<clippath
id=":rj:"
>
<rect
height="10"
width="10"
x="0"
y="0"
/>
</clippath>
</svg>
</div>
</div>
</div>
/>
<div
aria-hidden="true"
>
@@ -1950,55 +1662,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
<div
aria-hidden="true"
id="root-portal"
>
<div
data-floating-ui-portal=""
id=":rn:"
>
<div
class="tooltipContainer hidden-xs"
role="tooltip"
style="position: absolute; left: 0px; top: 0px; opacity: 0; transform: translate(-8px, 0px); transition-property: opacity; transition-duration: 150ms;"
tabindex="-1"
>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Channel options
</span>
</span>
</div>
<svg
aria-hidden="true"
height="10"
style="position: absolute; pointer-events: none; top: 0px; left: calc(100% - 0px); transform: rotate(-90deg);"
viewBox="0 0 10 10"
width="10"
>
<path
d="M0,0 H10 L5,6 Q5,6 5,6 Z"
stroke="none"
/>
<clippath
id=":ro:"
>
<rect
height="10"
width="10"
x="0"
y="0"
/>
</clippath>
</svg>
</div>
</div>
</div>
/>
<div
aria-hidden="true"
>
@@ -2291,55 +1955,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
<div
aria-hidden="true"
id="root-portal"
>
<div
data-floating-ui-portal=""
id=":rs:"
>
<div
class="tooltipContainer hidden-xs"
role="tooltip"
style="position: absolute; left: 0px; top: 0px; opacity: 0; transform: translate(-8px, 0px); transition-property: opacity; transition-duration: 150ms;"
tabindex="-1"
>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Channel options
</span>
</span>
</div>
<svg
aria-hidden="true"
height="10"
style="position: absolute; pointer-events: none; top: 0px; left: calc(100% - 0px); transform: rotate(-90deg);"
viewBox="0 0 10 10"
width="10"
>
<path
d="M0,0 H10 L5,6 Q5,6 5,6 Z"
stroke="none"
/>
<clippath
id=":rt:"
>
<rect
height="10"
width="10"
x="0"
y="0"
/>
</clippath>
</svg>
</div>
</div>
</div>
/>
<div
aria-hidden="true"
>
@@ -2632,55 +2248,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
<div
aria-hidden="true"
id="root-portal"
>
<div
data-floating-ui-portal=""
id=":rd:"
>
<div
class="tooltipContainer hidden-xs"
role="tooltip"
style="position: absolute; left: 0px; top: 0px; opacity: 0; transform: translate(-8px, 0px); transition-property: opacity; transition-duration: 150ms;"
tabindex="-1"
>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Channel options
</span>
</span>
</div>
<svg
aria-hidden="true"
height="10"
style="position: absolute; pointer-events: none; top: 0px; left: calc(100% - 0px); transform: rotate(-90deg);"
viewBox="0 0 10 10"
width="10"
>
<path
d="M0,0 H10 L5,6 Q5,6 5,6 Z"
stroke="none"
/>
<clippath
id=":re:"
>
<rect
height="10"
width="10"
x="0"
y="0"
/>
</clippath>
</svg>
</div>
</div>
</div>
/>
<div
aria-hidden="true"
>
@@ -2973,55 +2541,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
<div
aria-hidden="true"
id="root-portal"
>
<div
data-floating-ui-portal=""
id=":r8:"
>
<div
class="tooltipContainer hidden-xs"
role="tooltip"
style="position: absolute; left: 0px; top: 0px; opacity: 0; transform: translate(-8px, 0px); transition-property: opacity; transition-duration: 150ms;"
tabindex="-1"
>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Channel options
</span>
</span>
</div>
<svg
aria-hidden="true"
height="10"
style="position: absolute; pointer-events: none; top: 0px; left: calc(100% - 0px); transform: rotate(-90deg);"
viewBox="0 0 10 10"
width="10"
>
<path
d="M0,0 H10 L5,6 Q5,6 5,6 Z"
stroke="none"
/>
<clippath
id=":r9:"
>
<rect
height="10"
width="10"
x="0"
y="0"
/>
</clippath>
</svg>
</div>
</div>
</div>
/>
<div
aria-hidden="true"
>
@@ -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 () => {
@@ -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');
});
});
@@ -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();
}