mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-21 05:54:10 +08:00
[MM-68459] Implement dictionary style end user indicators for membership policies (#36240)
This commit is contained in:
@@ -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]);
|
||||
|
||||
+10
-490
@@ -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"
|
||||
>
|
||||
|
||||
+13
-1
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user