diff --git a/webapp/channels/src/components/channel_invite_modal/__snapshots__/channel_invite_modal.test.tsx.snap b/webapp/channels/src/components/channel_invite_modal/__snapshots__/channel_invite_modal.test.tsx.snap
index fa0a44ba20..c1d705e852 100644
--- a/webapp/channels/src/components/channel_invite_modal/__snapshots__/channel_invite_modal.test.tsx.snap
+++ b/webapp/channels/src/components/channel_invite_modal/__snapshots__/channel_invite_modal.test.tsx.snap
@@ -1,63 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/channel_invite_modal should match snapshot for channel_invite_modal with profiles 1`] = `
-
}
onExited={[MockFunction]}
onHide={[Function]}
- renderBackdrop={[Function]}
- restoreFocus={true}
- role="none"
show={true}
>
-
-
-
-
-
-
-
-
+
+
`;
exports[`components/channel_invite_modal should match snapshot for channel_invite_modal with profiles from DMs 1`] = `
-
}
onExited={[MockFunction]}
onHide={[Function]}
- renderBackdrop={[Function]}
- restoreFocus={true}
- role="none"
show={true}
>
-
-
-
-
-
-
-
-
+
+
`;
exports[`components/channel_invite_modal should match snapshot for channel_invite_modal with userStatuses 1`] = `
-
-`;
-
-exports[`components/channel_invite_modal should match snapshot with exclude and include users 1`] = `
-
}
onExited={[MockFunction]}
onHide={[Function]}
- renderBackdrop={[Function]}
- restoreFocus={true}
- role="none"
show={true}
>
-
-
-
-
-
-
-
-
+
+
+`;
+
+exports[`components/channel_invite_modal should match snapshot with exclude and include users 1`] = `
+
+ }
+ onExited={[MockFunction]}
+ onHide={[Function]}
+ show={true}
+>
+
+
`;
diff --git a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.scss b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.scss
new file mode 100644
index 0000000000..c3b26839fc
--- /dev/null
+++ b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.scss
@@ -0,0 +1,45 @@
+@import 'utils/mixins';
+
+.channel-invite {
+ &__wrapper {
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ &__content {
+ padding: 0;
+
+ .multi-select__container {
+ padding: 0 !important;
+ }
+
+ .multi-select__footer {
+ padding: 24px 0 !important;
+ }
+
+ .multi-select__wrapper {
+ margin: -10px 0 0 -5px !important;
+ }
+
+ .invite-as-guest {
+ margin-right: 0 !important;
+ }
+ }
+
+ &__username {
+ font-size: 12px;
+ }
+
+ &__user-mapping {
+ position: absolute;
+ right: 20px;
+ }
+
+ &__policy-banner {
+ .TagGroup {
+ margin-top: 12px;
+ }
+ }
+}
diff --git a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx
index 52cd413375..fb8a5b6360 100644
--- a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx
+++ b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx
@@ -1,23 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import {fireEvent, screen} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import React from 'react';
-import {Modal} from 'react-bootstrap';
+import {act} from 'react-dom/test-utils';
+import {GenericModal} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels';
+import type {TeamMembership} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {RelationOneToOne} from '@mattermost/types/utilities';
import {General} from 'mattermost-redux/constants';
import ChannelInviteModal from 'components/channel_invite_modal/channel_invite_modal';
-import type {ChannelInviteModal as ChannelInviteModalClass} from 'components/channel_invite_modal/channel_invite_modal';
import type {Value} from 'components/multiselect/multiselect';
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
+import {renderWithContext} from 'tests/react_testing_utils';
type UserProfileValue = Value & UserProfile;
+// Mock the useAccessControlAttributes hook
+jest.mock('components/common/hooks/useAccessControlAttributes', () => {
+ // Define the EntityType enum in the mock
+ const EntityType = {
+ Channel: 'channel',
+ };
+
+ const mockHook = jest.fn(() => ({
+ structuredAttributes: [
+ {
+ name: 'attribute1',
+ values: ['tag1', 'tag2'],
+ },
+ ],
+ loading: false,
+ error: null,
+ fetchAttributes: jest.fn(),
+ }));
+
+ // Export both the default export (the hook) and the named export (EntityType)
+ return {
+ __esModule: true,
+ default: mockHook,
+ EntityType,
+ };
+});
+
jest.mock('utils/utils', () => {
const original = jest.requireActual('utils/utils');
return {
@@ -31,11 +62,13 @@ describe('components/channel_invite_modal', () => {
id: 'user-1',
label: 'user-1',
value: 'user-1',
+ username: 'user-1',
delete_at: 0,
} as UserProfileValue, {
id: 'user-2',
label: 'user-2',
value: 'user-2',
+ username: 'user-2',
delete_at: 0,
} as UserProfileValue];
@@ -160,118 +193,236 @@ describe('components/channel_invite_modal', () => {
profilesFromRecentDMs={[]}
/>,
);
- const instance = wrapper.instance() as ChannelInviteModalClass;
- expect(instance.renderOption(users[0], true, jest.fn(), jest.fn())).toMatchSnapshot();
+
+ // Since renderOption is now an internal function in the component,
+ // we can't test it directly. Instead, we'll test the rendered component.
+ expect(wrapper).toMatchSnapshot();
});
- test('should match state when onHide is called', () => {
+ test('should hide modal when onHide is called', () => {
const wrapper = shallowWithIntl(
,
);
- wrapper.setState({show: true});
+ // Find the GenericModal and trigger its onHide prop
+ const modal = wrapper.find(GenericModal);
+ const onHide = modal.props().onHide;
+ if (onHide) {
+ onHide();
+ }
- const instance = wrapper.instance() as ChannelInviteModalClass;
- instance.onHide();
+ // Re-render to reflect state changes
+ wrapper.update();
- expect(wrapper.state('show')).toEqual(false);
+ // The modal should now be hidden (show prop should be false)
+ expect(wrapper.find(GenericModal).props().show).toEqual(false);
});
- test('should have called props.onHide when Modal.onExited is called', () => {
+ test('should have called props.onExited when GenericModal.onExited is called', () => {
const props = {...baseProps};
const wrapper = shallowWithIntl(
,
);
- wrapper.find(Modal).props().onExited!(document.createElement('div'));
+ const modal = wrapper.find(GenericModal);
+ const onExited = modal.props().onExited;
+ if (onExited) {
+ onExited();
+ }
expect(props.onExited).toHaveBeenCalledTimes(1);
});
- test('should fail to add users on handleSubmit', (done) => {
- const wrapper = shallowWithIntl(
- ,
- );
-
- wrapper.setState({selectedUsers: users, show: true});
-
- const instance = wrapper.instance() as ChannelInviteModalClass;
- instance.handleSubmit();
-
- expect(wrapper.state('saving')).toEqual(true);
- expect(instance.props.actions.addUsersToChannel).toHaveBeenCalledTimes(1);
- process.nextTick(() => {
- expect(wrapper.state('inviteError')).toEqual('Failed');
- expect(wrapper.state('saving')).toEqual(false);
- done();
+ test('should fail to add users on handleSubmit', async () => {
+ // Mock the addUsersToChannel function to return an error
+ const addUsersToChannelMock = jest.fn().mockImplementation(() => {
+ return Promise.resolve({error: {message: 'Failed'}});
});
- });
- test('should add users on handleSubmit', (done) => {
const props = {
...baseProps,
actions: {
...baseProps.actions,
- addUsersToChannel: jest.fn().mockImplementation(() => {
- const data = true;
- return Promise.resolve({data});
- }),
+ addUsersToChannel: addUsersToChannelMock,
},
+ profilesNotInCurrentChannel: [users[0]],
+ includeUsers: {'user-1': users[0]},
+ membersInTeam: {'user-1': {user_id: 'user-1', team_id: channel.team_id, roles: '', delete_at: 0, scheme_admin: false, scheme_guest: false, scheme_user: true, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0} as TeamMembership},
};
- const wrapper = shallowWithIntl(
- ,
- );
+ await act(async () => {
+ const {getByText} = renderWithContext(
+ ,
+ );
- wrapper.setState({selectedUsers: users, show: true});
+ // First, we need to simulate selecting a user
+ const input = screen.getByRole('combobox', {name: /search for people/i});
- const instance = wrapper.instance() as ChannelInviteModalClass;
- instance.handleSubmit();
+ // Type the search term
+ await userEvent.type(input, 'user-1');
- expect(wrapper.state('saving')).toEqual(true);
- expect(instance.props.actions.addUsersToChannel).toHaveBeenCalledTimes(1);
- process.nextTick(() => {
- expect(wrapper.state('inviteError')).toBeUndefined();
- expect(wrapper.state('saving')).toEqual(false);
- expect(wrapper.state('show')).toEqual(false);
- done();
+ // Wait for the promise to resolve
+ await act(async () => {
+ // Wait for the dropdown option to appear
+ const option = await screen.findByText('user-1');
+
+ // Click the option
+ userEvent.click(option);
+
+ // Confirm that the user is now displayed in the selected users
+ expect(screen.getByText('user-1')).toBeInTheDocument();
+
+ // Find and click the save button
+ const saveButton = getByText('Add');
+ fireEvent.click(saveButton);
+ });
+
+ // Wait for the promise to resolve
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ // Check that addUsersToChannel was called
+ expect(addUsersToChannelMock).toHaveBeenCalled();
});
});
- test('should call onAddCallback on handleSubmit with skipCommit', () => {
+ test('should add users on handleSubmit', async () => {
+ // Mock the addUsersToChannel function to return success
+ const addUsersToChannelMock = jest.fn().mockImplementation(() => {
+ return Promise.resolve({data: true});
+ });
+
+ const props = {
+ ...baseProps,
+ actions: {
+ ...baseProps.actions,
+ addUsersToChannel: addUsersToChannelMock,
+ },
+ profilesNotInCurrentChannel: [users[0]],
+ includeUsers: {'user-1': users[0]},
+ membersInTeam: {'user-1': {user_id: 'user-1', team_id: channel.team_id, roles: '', delete_at: 0, scheme_admin: false, scheme_guest: false, scheme_user: true, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0} as TeamMembership},
+ };
+
+ await act(async () => {
+ const {getByText} = renderWithContext(
+ ,
+ );
+
+ // First, we need to simulate selecting a user
+ const input = screen.getByRole('combobox', {name: /search for people/i});
+
+ // Type the search term
+ await userEvent.type(input, 'user-1');
+
+ // Wait for the promise to resolve
+ await act(async () => {
+ // Wait for the dropdown option to appear
+ const option = await screen.findByText('user-1');
+
+ // Click the option
+ userEvent.click(option);
+
+ // Confirm that the user is now displayed in the selected users
+ expect(screen.getByText('user-1')).toBeInTheDocument();
+
+ // Find and click the save button
+ const saveButton = getByText('Add');
+ fireEvent.click(saveButton);
+ });
+
+ // Wait for the promise to resolve
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ // Check that addUsersToChannel was called
+ expect(addUsersToChannelMock).toHaveBeenCalled();
+ });
+ });
+
+ test('should call onAddCallback on handleSubmit with skipCommit', async () => {
const onAddCallback = jest.fn();
+
const props = {
...baseProps,
skipCommit: true,
onAddCallback,
+ profilesNotInCurrentChannel: [users[0]],
+ includeUsers: {'user-1': users[0]},
+ membersInTeam: {'user-1': {user_id: 'user-1', team_id: channel.team_id, roles: '', delete_at: 0, scheme_admin: false, scheme_guest: false, scheme_user: true, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0} as TeamMembership},
+
};
- const wrapper = shallowWithIntl(
- ,
- );
+ await act(async () => {
+ const {getByText} = renderWithContext(
+ ,
+ );
- wrapper.setState({selectedUsers: users, show: true});
- const instance = wrapper.instance() as ChannelInviteModalClass;
- instance.handleSubmit();
+ // First, we need to simulate selecting a user
+ const input = screen.getByRole('combobox', {name: /search for people/i});
- expect(onAddCallback).toHaveBeenCalled();
- expect(instance.props.actions.addUsersToChannel).toHaveBeenCalledTimes(0);
+ await userEvent.type(input, 'user-1');
+
+ await act(async () => {
+ const option = await screen.findByText('user-1');
+
+ userEvent.click(option);
+
+ expect(screen.getByText('user-1')).toBeInTheDocument();
+
+ const saveButton = getByText('Add');
+ fireEvent.click(saveButton);
+ });
+
+ // Check that onAddCallback was called and addUsersToChannel was not
+ expect(onAddCallback).toHaveBeenCalled();
+ expect(props.actions.addUsersToChannel).not.toHaveBeenCalled();
+ });
});
- test('should trim the search term', () => {
- const wrapper = shallowWithIntl(
- ,
- );
+ test('should trim the search term', async () => {
+ const searchProfilesMock = jest.fn().mockImplementation(() => {
+ return Promise.resolve({});
+ });
- const instance = wrapper.instance() as ChannelInviteModalClass;
+ const props = {
+ ...baseProps,
+ actions: {
+ ...baseProps.actions,
+ searchProfiles: searchProfilesMock,
+ },
+ };
- instance.search(' something ');
- expect(wrapper.state('term')).toEqual('something');
+ await act(async () => {
+ renderWithContext(
+ ,
+ );
+
+ // Find the search input
+ const input = screen.getByRole('combobox', {name: /search for people/i});
+
+ // Directly trigger the change event with a value that has spaces
+ fireEvent.change(input, {target: {value: ' something '}});
+
+ // Wait for the search timeout plus some extra time
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ });
+
+ // Verify the search was called with the trimmed term
+ expect(searchProfilesMock).toHaveBeenCalledWith(
+ expect.stringContaining('something'),
+ expect.any(Object),
+ );
+ });
});
test('should send the invite as guest param through the link', () => {
@@ -305,4 +456,137 @@ describe('components/channel_invite_modal', () => {
expect(invitationLink).toHaveLength(0);
});
+
+ test('should show AlertBanner when policy_enforced is true', () => {
+ const channelWithPolicy = {
+ ...channel,
+ policy_enforced: true,
+ };
+
+ const props = {
+ ...baseProps,
+ channel: channelWithPolicy,
+ };
+
+ const wrapper = shallowWithIntl(
+ ,
+ );
+
+ // Check that the AlertBanner is shown
+ expect(wrapper.find('AlertBanner').exists()).toBe(true);
+ });
+
+ test('should show attribute tags in AlertBanner', () => {
+ const channelWithPolicy = {
+ ...channel,
+ policy_enforced: true,
+ };
+
+ const props = {
+ ...baseProps,
+ channel: channelWithPolicy,
+ };
+
+ const wrapper = shallowWithIntl(
+ ,
+ );
+
+ // Check that the AlertBanner is shown
+ expect(wrapper.find('AlertBanner').exists()).toBe(true);
+
+ // Check that the TagGroup exists
+ expect(wrapper.find('TagGroup').exists()).toBe(true);
+
+ // Check that the attribute tags are shown
+ const tagGroup = wrapper.find('TagGroup');
+ const alertTags = tagGroup.find('AlertTag');
+ expect(alertTags).toHaveLength(2);
+
+ // Verify the tag text
+ expect(alertTags.at(0).prop('text')).toBe('tag1');
+ expect(alertTags.at(1).prop('text')).toBe('tag2');
+ });
+
+ test('should not show AlertBanner when policy_enforced is false', () => {
+ const channelWithoutPolicy = {
+ ...channel,
+ policy_enforced: false,
+ };
+
+ const props = {
+ ...baseProps,
+ channel: channelWithoutPolicy,
+ };
+
+ const wrapper = shallowWithIntl(
+ ,
+ );
+
+ // Check that the AlertBanner is not shown
+ expect(wrapper.find('AlertBanner').exists()).toBe(false);
+ });
+
+ test('should show loading state for access attributes', () => {
+ // Mock the useAccessControlAttributes hook to return loading state
+ const useAccessControlAttributesModule = require('components/common/hooks/useAccessControlAttributes');
+ const useAccessControlAttributesMock = useAccessControlAttributesModule.default;
+ useAccessControlAttributesMock.mockReturnValueOnce({
+ structuredAttributes: [],
+ loading: true,
+ error: null,
+ fetchAttributes: jest.fn(),
+ });
+
+ const channelWithPolicy = {
+ ...channel,
+ policy_enforced: true,
+ };
+
+ const props = {
+ ...baseProps,
+ channel: channelWithPolicy,
+ };
+
+ const wrapper = shallowWithIntl(
+ ,
+ );
+
+ // Check that the AlertBanner is shown
+ expect(wrapper.find('AlertBanner').exists()).toBe(true);
+
+ // Check that no tags are shown
+ expect(wrapper.find('AlertTag').exists()).toBe(false);
+ });
+
+ test('should handle error state for access attributes', () => {
+ // Mock the useAccessControlAttributes hook to return error state
+ const useAccessControlAttributesModule = require('components/common/hooks/useAccessControlAttributes');
+ const useAccessControlAttributesMock = useAccessControlAttributesModule.default;
+ useAccessControlAttributesMock.mockReturnValueOnce({
+ structuredAttributes: [],
+ loading: false,
+ error: 'Failed to load attributes',
+ fetchAttributes: jest.fn(),
+ });
+
+ const channelWithPolicy = {
+ ...channel,
+ policy_enforced: true,
+ };
+
+ const props = {
+ ...baseProps,
+ channel: channelWithPolicy,
+ };
+
+ const wrapper = shallowWithIntl(
+ ,
+ );
+
+ // Check that the AlertBanner is shown
+ expect(wrapper.find('AlertBanner').exists()).toBe(true);
+
+ // Check that no tags are shown
+ expect(wrapper.find('AlertTag').exists()).toBe(false);
+ });
});
diff --git a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx
index 00a437a98b..ab858b19a0 100644
--- a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx
+++ b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx
@@ -1,13 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import './channel_invite_modal.scss';
+
import isEqual from 'lodash/isEqual';
-import React from 'react';
-import {Modal} from 'react-bootstrap';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {IntlShape} from 'react-intl';
import {injectIntl, FormattedMessage, defineMessage} from 'react-intl';
-import styled from 'styled-components';
+import {GenericModal} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels';
import type {Group, GroupSearchParams} from '@mattermost/types/groups';
import type {TeamMembership} from '@mattermost/types/teams';
@@ -19,13 +20,17 @@ import type {ActionResult} from 'mattermost-redux/types/actions';
import {filterGroupsMatchingTerm} from 'mattermost-redux/utils/group_utils';
import {displayUsername, filterProfilesStartingWithTerm, isGuest} from 'mattermost-redux/utils/user_utils';
+import AlertBanner from 'components/alert_banner';
+import useAccessControlAttributes, {EntityType} from 'components/common/hooks/useAccessControlAttributes';
import InvitationModal from 'components/invitation_modal';
import MultiSelect from 'components/multiselect/multiselect';
import type {Value} from 'components/multiselect/multiselect';
import ProfilePicture from 'components/profile_picture';
import ToggleModalButton from 'components/toggle_modal_button';
+import AlertTag from 'components/widgets/tag/alert_tag';
import BotTag from 'components/widgets/tag/bot_tag';
import GuestTag from 'components/widgets/tag/guest_tag';
+import TagGroup from 'components/widgets/tag/tag_group';
import Constants, {ModalIdentifiers} from 'utils/constants';
import {sortUsersAndGroups} from 'utils/utils';
@@ -79,161 +84,106 @@ export type Props = {
};
}
-type State = {
- selectedUsers: UserProfileValue[];
- groupAndUserOptions: Array;
- usersNotInTeam: UserProfileValue[];
- guestsNotInTeam: UserProfileValue[];
- term: string;
- show: boolean;
- saving: boolean;
- loadingUsers: boolean;
- inviteError?: string;
-}
+// Helper function to check if an option is a user
+const isUser = (option: UserProfileValue | GroupValue): option is UserProfileValue => {
+ return (option as UserProfile).username !== undefined;
+};
-const UsernameSpan = styled.span`
- fontSize: 12px;
-`;
+const ChannelInviteModalComponent = (props: Props) => {
+ const [selectedUsers, setSelectedUsers] = useState([]);
+ const [usersNotInTeam, setUsersNotInTeam] = useState([]);
+ const [guestsNotInTeam, setGuestsNotInTeam] = useState([]);
+ const [term, setTerm] = useState('');
+ const [show, setShow] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [loadingUsers, setLoadingUsers] = useState(true);
+ const [groupAndUserOptions, setGroupAndUserOptions] = useState>([]);
+ const [inviteError, setInviteError] = useState(undefined);
-const UserMappingSpan = styled.span`
- position: absolute;
- right: 20px;
-`;
+ const searchTimeoutId = useRef(0);
+ const selectedItemRef = useRef(null);
-export class ChannelInviteModal extends React.PureComponent {
- private searchTimeoutId = 0;
- private selectedItemRef = React.createRef();
+ // Use the useAccessControlAttributes hook
+ const {structuredAttributes} = useAccessControlAttributes(
+ EntityType.Channel,
+ props.channel.id,
+ props.channel.policy_enforced,
+ );
- public static defaultProps = {
- includeUsers: {},
- excludeUsers: {},
- skipCommit: false,
+ // 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());
};
- constructor(props: Props) {
- super(props);
- this.state = {
- selectedUsers: [],
- usersNotInTeam: [],
- guestsNotInTeam: [],
- term: '',
- show: true,
- saving: false,
- loadingUsers: true,
- groupAndUserOptions: [],
- } as State;
- }
-
- isUser = (option: UserProfileValue | GroupValue): option is UserProfileValue => {
- return (option as UserProfile).username !== undefined;
- };
-
- private addValue = (value: UserProfileValue | GroupValue): void => {
- if (this.isUser(value)) {
+ // Helper function to add a user or group to the selected list
+ const addValue = useCallback((value: UserProfileValue | GroupValue) => {
+ if (isUser(value)) {
const profile = value;
- if (!this.props.membersInTeam || !this.props.membersInTeam[profile.id]) {
+ if (!props.membersInTeam || !props.membersInTeam[profile.id]) {
if (isGuest(profile.roles)) {
- if (this.state.guestsNotInTeam.indexOf(profile) === -1) {
- this.setState((prevState) => {
- return {guestsNotInTeam: [...prevState.guestsNotInTeam, profile]};
- });
- }
+ setGuestsNotInTeam((prevState) => {
+ if (prevState.findIndex((p) => p.id === profile.id) === -1) {
+ return [...prevState, profile];
+ }
+ return prevState;
+ });
return;
}
- if (this.state.usersNotInTeam.indexOf(profile) === -1) {
- this.setState((prevState) => {
- return {usersNotInTeam: [...prevState.usersNotInTeam, profile]};
- });
- }
+ setUsersNotInTeam((prevState) => {
+ if (prevState.findIndex((p) => p.id === profile.id) === -1) {
+ return [...prevState, profile];
+ }
+ return prevState;
+ });
return;
}
- if (this.state.selectedUsers.indexOf(profile) === -1) {
- this.setState((prevState) => {
- return {selectedUsers: [...prevState.selectedUsers, profile]};
- });
- }
- }
- };
-
- private removeInvitedUsers = (profiles: UserProfile[]): void => {
- const usersNotInTeam = this.state.usersNotInTeam.filter((profile) => {
- const user = profile as UserProfileValue;
-
- const index = profiles.indexOf(user);
- if (index === -1) {
- return true;
- }
- this.addValue(user);
- return false;
- });
-
- this.setState({usersNotInTeam: [...usersNotInTeam], guestsNotInTeam: []});
- };
-
- private removeUsersFromValuesNotInTeam = (profiles: UserProfile[]): void => {
- const usersNotInTeam = this.state.usersNotInTeam.filter((profile) => {
- const index = profiles.indexOf(profile);
- return index === -1;
- });
- this.setState({usersNotInTeam: [...usersNotInTeam], guestsNotInTeam: []});
- };
-
- public componentDidMount(): void {
- this.props.actions.getProfilesNotInChannel(this.props.channel.team_id, this.props.channel.id, this.props.channel.group_constrained, 0).then(() => {
- this.setUsersLoadingState(false);
- });
- this.props.actions.getProfilesInChannel(this.props.channel.id, 0, USERS_PER_PAGE, '', {active: true});
- this.props.actions.getTeamStats(this.props.channel.team_id);
- this.props.actions.loadStatusesForProfilesList(this.props.profilesNotInCurrentChannel);
- this.props.actions.loadStatusesForProfilesList(this.props.profilesInCurrentChannel);
- }
-
- public async componentDidUpdate(prevProps: Props, prevState: State) {
- if (prevState.term !== this.state.term) {
- const values = this.getOptions();
- const userIds: string[] = [];
-
- for (let index = 0; index < values.length; index++) {
- const newValue = values[index];
- if (this.isUser(newValue)) {
- userIds.push(newValue.id);
- } else if (newValue.member_ids) {
- userIds.push(...newValue.member_ids);
+ setSelectedUsers((prevState) => {
+ if (prevState.findIndex((p) => p.id === profile.id) === -1) {
+ return [...prevState, profile];
}
- }
-
- if (!isEqual(values, this.state.groupAndUserOptions)) {
- if (userIds.length > 0) {
- this.props.actions.getTeamMembersByIds(this.props.channel.team_id, userIds);
- }
- this.setState({groupAndUserOptions: values});
- }
+ return prevState;
+ });
}
- }
+ }, [props.membersInTeam, isGuest]);
- getExcludedUsers = (): Set => {
- if (this.props.excludeUsers) {
- return new Set(...this.props.profilesNotInCurrentTeam.map((user) => user.id), Object.values(this.props.excludeUsers).map((user) => user.id));
+ // Get excluded users
+ const excludedUsers = useMemo(() => {
+ if (props.excludeUsers) {
+ return new Set([
+ ...props.profilesNotInCurrentTeam.map((user) => user.id),
+ ...Object.values(props.excludeUsers).map((user) => user.id),
+ ]);
}
- return new Set(this.props.profilesNotInCurrentTeam.map((user) => user.id));
- };
+ return new Set(props.profilesNotInCurrentTeam.map((user) => user.id));
+ }, [props.excludeUsers, props.profilesNotInCurrentTeam]);
- // Options list prioritizes recent dms for the first 10 users and then the next 15 are a mix of users and groups
- public getOptions = () => {
- const excludedAndNotInTeamUserIds = this.getExcludedUsers();
+ // Filter out deleted and excluded users
+ const filterOutDeletedAndExcludedAndNotInTeamUsers = useCallback((users: UserProfile[], excludeUserIds: Set): UserProfileValue[] => {
+ return users.filter((user) => {
+ return user.delete_at === 0 && !excludeUserIds.has(user.id);
+ }) as UserProfileValue[];
+ }, []);
- const filteredDmUsers = filterProfilesStartingWithTerm(this.props.profilesFromRecentDMs, this.state.term);
- const dmUsers = this.filterOutDeletedAndExcludedAndNotInTeamUsers(filteredDmUsers, excludedAndNotInTeamUserIds).slice(0, USERS_FROM_DMS) as UserProfileValue[];
+ // Get options for the multiselect
+ const getOptions = useCallback(() => {
+ const excludedAndNotInTeamUserIds = excludedUsers;
+
+ const filteredDmUsers = filterProfilesStartingWithTerm(props.profilesFromRecentDMs, term);
+ const dmUsers = filterOutDeletedAndExcludedAndNotInTeamUsers(filteredDmUsers, excludedAndNotInTeamUserIds).slice(0, USERS_FROM_DMS) as UserProfileValue[];
let users: UserProfileValue[];
- const filteredUsers: UserProfile[] = filterProfilesStartingWithTerm(this.props.profilesNotInCurrentChannel.concat(this.props.profilesInCurrentChannel), this.state.term);
- users = this.filterOutDeletedAndExcludedAndNotInTeamUsers(filteredUsers, excludedAndNotInTeamUserIds);
- if (this.props.includeUsers) {
- users = [...users, ...Object.values(this.props.includeUsers)];
+ const filteredUsers: UserProfile[] = filterProfilesStartingWithTerm(props.profilesNotInCurrentChannel.concat(props.profilesInCurrentChannel), term);
+ users = filterOutDeletedAndExcludedAndNotInTeamUsers(filteredUsers, excludedAndNotInTeamUserIds);
+ if (props.includeUsers) {
+ users = [...users, ...Object.values(props.includeUsers)];
}
const groupsAndUsers = [
- ...filterGroupsMatchingTerm(this.props.groups, this.state.term) as GroupValue[],
+ ...filterGroupsMatchingTerm(props.groups, term) as GroupValue[],
...users,
].sort(sortUsersAndGroups);
@@ -243,98 +193,106 @@ export class ChannelInviteModal extends React.PureComponent {
].slice(0, MAX_USERS);
return Array.from(new Set(optionValues));
- };
+ }, [
+ term,
+ props.profilesFromRecentDMs,
+ props.profilesNotInCurrentChannel,
+ props.profilesInCurrentChannel,
+ props.includeUsers,
+ props.groups,
+ excludedUsers,
+ filterOutDeletedAndExcludedAndNotInTeamUsers,
+ ]);
- public onHide = (): void => {
- this.setState({show: false});
- this.props.actions.loadStatusesForProfilesList(this.props.profilesNotInCurrentChannel);
- this.props.actions.loadStatusesForProfilesList(this.props.profilesInCurrentChannel);
- };
+ // Handle modal hide
+ const onHide = useCallback(() => {
+ setShow(false);
+ props.actions.loadStatusesForProfilesList(props.profilesNotInCurrentChannel);
+ props.actions.loadStatusesForProfilesList(props.profilesInCurrentChannel);
+ }, [props.actions, props.profilesNotInCurrentChannel, props.profilesInCurrentChannel]);
- public handleInviteError = (err: any): void => {
+ // Handle invite error
+ const handleInviteError = useCallback((err: any) => {
if (err) {
- this.setState({
- saving: false,
- inviteError: err.message,
- });
+ setSaving(false);
+ setInviteError(err.message);
}
- };
+ }, []);
- private handleDelete = (values: Array): void => {
+ // Handle delete (removing users from selection)
+ const handleDelete = useCallback((values: Array) => {
// Our values for this component are always UserProfileValue
const profiles = values as UserProfileValue[];
- this.setState({selectedUsers: profiles});
- };
+ setSelectedUsers(profiles);
+ }, []);
- private setUsersLoadingState = (loadingState: boolean): void => {
- this.setState({
- loadingUsers: loadingState,
- });
- };
+ // Set users loading state
+ const setUsersLoadingState = useCallback((loadingState: boolean) => {
+ setLoadingUsers(loadingState);
+ }, []);
- private handlePageChange = (page: number, prevPage: number): void => {
+ // Handle page change
+ const handlePageChange = useCallback((page: number, prevPage: number) => {
if (page > prevPage) {
- this.setUsersLoadingState(true);
- this.props.actions.getProfilesNotInChannel(
- this.props.channel.team_id,
- this.props.channel.id,
- this.props.channel.group_constrained,
- page + 1, USERS_PER_PAGE).then(() => this.setUsersLoadingState(false));
+ setUsersLoadingState(true);
+ props.actions.getProfilesNotInChannel(
+ props.channel.team_id,
+ props.channel.id,
+ props.channel.group_constrained,
+ page + 1, USERS_PER_PAGE).then(() => setUsersLoadingState(false));
- this.props.actions.getProfilesInChannel(this.props.channel.id, page + 1, USERS_PER_PAGE, '', {active: true});
+ props.actions.getProfilesInChannel(props.channel.id, page + 1, USERS_PER_PAGE, '', {active: true});
}
- };
+ }, [props.actions, props.channel, setUsersLoadingState]);
- public handleSubmit = (): void => {
- const {actions, channel} = this.props;
+ // Handle form submission
+ const handleSubmit = useCallback(() => {
+ const {actions, channel} = props;
- const userIds = this.state.selectedUsers.map((u) => u.id);
+ const userIds = selectedUsers.map((u) => u.id);
if (userIds.length === 0) {
return;
}
- if (this.props.skipCommit && this.props.onAddCallback) {
- this.props.onAddCallback(this.state.selectedUsers);
- this.setState({
- saving: false,
- inviteError: undefined,
- });
- this.onHide();
+ if (props.skipCommit && props.onAddCallback) {
+ props.onAddCallback(selectedUsers);
+ setSaving(false);
+ setInviteError(undefined);
+ onHide();
return;
}
- this.setState({saving: true});
+ setSaving(true);
actions.addUsersToChannel(channel.id, userIds).then((result) => {
if (result.error) {
- this.handleInviteError(result.error);
+ handleInviteError(result.error);
} else {
- this.setState({
- saving: false,
- inviteError: undefined,
- });
- this.onHide();
+ setSaving(false);
+ setInviteError(undefined);
+ onHide();
}
});
- };
+ }, [props, selectedUsers, handleInviteError, onHide]);
- public search = (searchTerm: string): void => {
+ // Handle search
+ const search = useCallback((searchTerm: string) => {
const term = searchTerm.trim();
- clearTimeout(this.searchTimeoutId);
- this.setState({
- term,
- });
+ clearTimeout(searchTimeoutId.current);
+ setTerm(term);
- this.searchTimeoutId = window.setTimeout(
+ if (!term) {
+ // If the search term is empty, don't make any API calls
+ setUsersLoadingState(false);
+ return;
+ }
+
+ searchTimeoutId.current = window.setTimeout(
async () => {
- if (!term) {
- return;
- }
-
const options = {
- team_id: this.props.channel.team_id,
- not_in_channel_id: this.props.channel.id,
- group_constrained: this.props.channel.group_constrained,
+ team_id: props.channel.team_id,
+ not_in_channel_id: props.channel.id,
+ group_constrained: Boolean(props.channel.group_constrained),
};
const opts = {
@@ -346,59 +304,55 @@ export class ChannelInviteModal extends React.PureComponent {
include_member_ids: true,
};
const promises = [
- this.props.actions.searchProfiles(term, options),
+ props.actions.searchProfiles(term, options),
];
- if (this.props.isGroupsEnabled) {
- promises.push(this.props.actions.searchAssociatedGroupsForReference(term, this.props.channel.team_id, this.props.channel.id, opts));
+ if (props.isGroupsEnabled) {
+ promises.push(props.actions.searchAssociatedGroupsForReference(term, props.channel.team_id, props.channel.id, opts));
}
await Promise.all(promises);
- this.setUsersLoadingState(false);
+ setUsersLoadingState(false);
},
Constants.SEARCH_TIMEOUT_MILLISECONDS,
);
- };
+ }, [props.actions, props.channel, props.isGroupsEnabled, setUsersLoadingState]);
- private renderAriaLabel = (option: UserProfileValue | GroupValue): string => {
+ // Render aria label for options
+ const renderAriaLabel = useCallback((option: UserProfileValue | GroupValue): string => {
if (!option) {
return '';
}
- if (this.isUser(option)) {
+ if (isUser(option)) {
return option.username;
}
return option.name;
- };
+ }, []);
- private filterOutDeletedAndExcludedAndNotInTeamUsers = (users: UserProfile[], excludeUserIds: Set): UserProfileValue[] => {
- return users.filter((user) => {
- return user.delete_at === 0 && !excludeUserIds.has(user.id);
- }) as UserProfileValue[];
- };
-
- renderOption = (option: UserProfileValue | GroupValue, isSelected: boolean, onAdd: (option: UserProfileValue | GroupValue) => void, onMouseMove: (option: UserProfileValue | GroupValue) => void) => {
+ // Render option for multiselect
+ const renderOption = useCallback((option: UserProfileValue | GroupValue, isSelected: boolean, onAdd: (option: UserProfileValue | GroupValue) => void, onMouseMove: (option: UserProfileValue | GroupValue) => void) => {
let rowSelected = '';
if (isSelected) {
rowSelected = 'more-modal__row--selected';
}
- if (this.isUser(option)) {
- const ProfilesInGroup = this.props.profilesInCurrentChannel.map((user) => user.id);
+ if (isUser(option)) {
+ const ProfilesInGroup = props.profilesInCurrentChannel.map((user) => user.id);
const userMapping: Record = {};
for (let i = 0; i < ProfilesInGroup.length; i++) {
userMapping[ProfilesInGroup[i]] = 'Already in channel';
}
- const displayName = displayUsername(option, this.props.teammateNameDisplaySetting);
+ const displayName = displayUsername(option, props.teammateNameDisplaySetting);
return (
onAdd(option)}
onMouseMove={() => onMouseMove(option)}
>
@@ -408,15 +362,15 @@ export class ChannelInviteModal extends React.PureComponent
{
{displayName}
{option.is_bot && }
{isGuest(option.roles) && }
- {displayName === option.username ? null :
+ {displayName === option.username ? null :
{'@'}{option.username}
-
+
}
-
{userMapping[option.id]}
-
+
@@ -442,146 +396,226 @@ export class ChannelInviteModal extends React.PureComponent {
isSelected={isSelected}
rowSelected={rowSelected}
onMouseMove={onMouseMove}
- selectedItemRef={this.selectedItemRef}
+ selectedItemRef={selectedItemRef}
/>
);
- };
+ }, [props.profilesInCurrentChannel, props.teammateNameDisplaySetting, props.userStatuses]);
- public render = (): JSX.Element => {
- let inviteError = null;
- if (this.state.inviteError) {
- inviteError = ();
+ // Initial data loading - only run when channel changes or component mounts
+ useEffect(() => {
+ props.actions.getProfilesNotInChannel(props.channel.team_id, props.channel.id, props.channel.group_constrained, 0).then(() => {
+ setUsersLoadingState(false);
+ });
+ props.actions.getProfilesInChannel(props.channel.id, 0, USERS_PER_PAGE, '', {active: true});
+ props.actions.getTeamStats(props.channel.team_id);
+ props.actions.loadStatusesForProfilesList(props.profilesNotInCurrentChannel);
+ props.actions.loadStatusesForProfilesList(props.profilesInCurrentChannel);
+ }, [
+ props.channel.id,
+ props.channel.team_id,
+ props.channel.group_constrained,
+ props.actions,
+
+ // Removing these dependencies as they cause an infinite loop
+ // These profiles are updated by the actions above, which triggers the effect again
+ // props.profilesNotInCurrentChannel,
+ // props.profilesInCurrentChannel,
+ ]);
+
+ // Compute options with useMemo to ensure they're always fresh
+ const computedOptions = useMemo(() => getOptions(), [
+ term,
+ props.profilesFromRecentDMs,
+ props.profilesNotInCurrentChannel,
+ props.profilesInCurrentChannel,
+ props.includeUsers,
+ props.groups,
+ props.profilesNotInCurrentTeam,
+ props.excludeUsers,
+ ]);
+
+ // Update team members when options change
+ useEffect(() => {
+ const userIds: string[] = [];
+
+ for (let index = 0; index < computedOptions.length; index++) {
+ const newValue = computedOptions[index];
+ if (isUser(newValue)) {
+ userIds.push(newValue.id);
+ } else if (newValue.member_ids) {
+ userIds.push(...newValue.member_ids);
+ }
}
- const buttonSubmitText = defineMessage({id: 'multiselect.add', defaultMessage: 'Add'});
- const buttonSubmitLoadingText = defineMessage({id: 'multiselect.adding', defaultMessage: 'Adding...'});
+ if (!isEqual(computedOptions, groupAndUserOptions)) {
+ if (userIds.length > 0) {
+ props.actions.getTeamMembersByIds(props.channel.team_id, userIds);
+ }
+ setGroupAndUserOptions(computedOptions);
+ }
+ }, [computedOptions, props.actions, props.channel.team_id]);
- const closeMembersInviteModal = () => {
- this.props.actions.closeModal(ModalIdentifiers.CHANNEL_INVITE);
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ clearTimeout(searchTimeoutId.current);
};
+ }, []);
- const InviteModalLink = (props: {inviteAsGuest?: boolean; children: React.ReactNode; id?: string}) => {
- return (
-
- {props.children}
-
- );
- };
+ // Render the component
+ const buttonSubmitText = defineMessage({id: 'multiselect.add', defaultMessage: 'Add'});
+ const buttonSubmitLoadingText = defineMessage({id: 'multiselect.adding', defaultMessage: 'Adding...'});
- const customNoOptionsMessage = (
-
- (
-
- {chunks}
-
- ),
- }}
- />
-
- );
-
- const content = (
-
- );
-
- const inviteGuestLink = (
-
-
-
- );
+ const closeMembersInviteModal = () => {
+ props.actions.closeModal(ModalIdentifiers.CHANNEL_INVITE);
+ };
+ const InviteModalLink = (props: {inviteAsGuest?: boolean; children: React.ReactNode; id?: string}) => {
return (
-
-
-
-
-
-
-
- {inviteError}
-
- {content}
-
- {(this.props.emailInvitationsEnabled && this.props.canInviteGuests) && inviteGuestLink}
-
-
-
+ {props.children}
+
);
};
-}
-export default injectIntl(ChannelInviteModal);
+ const customNoOptionsMessage = (
+
+ (
+
+ {chunks}
+
+ ),
+ }}
+ />
+
+ );
+
+ const content = (
+
+ );
+
+ const inviteGuestLink = (
+
+
+
+ );
+
+ const {channel} = props;
+
+ return (
+
+ }
+ compassDesign={true}
+ bodyOverflowVisible={true}
+ >
+
+ {inviteError &&
}
+ {(channel.policy_enforced) && (
+
+
+ )}
+ message={(
+
+ )}
+ >
+ {structuredAttributes.length > 0 && (
+
+ {structuredAttributes.flatMap((attribute) =>
+ attribute.values.map((value) => (
+
+ )),
+ )}
+
+ )}
+
+
+ )}
+
+ {content}
+
+ {(props.emailInvitationsEnabled && props.canInviteGuests) && inviteGuestLink}
+
+
+
+ );
+};
+
+export default injectIntl(ChannelInviteModalComponent);
diff --git a/webapp/channels/src/components/channel_members_rhs/channel_members_rhs.scss b/webapp/channels/src/components/channel_members_rhs/channel_members_rhs.scss
new file mode 100644
index 0000000000..bcd8f25472
--- /dev/null
+++ b/webapp/channels/src/components/channel_members_rhs/channel_members_rhs.scss
@@ -0,0 +1,150 @@
+.channel-members-rhs {
+ &__members-container {
+ flex: 1 1 auto;
+ padding: 0 4px 16px;
+ }
+
+ &__member-list-separator {
+ padding: 0px 12px;
+ margin-top: 16px;
+ color: rgba(var(--center-channel-color-rgb), 0.75);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ line-height: 28px;
+ text-transform: uppercase;
+
+ &--first {
+ margin-top: 0px;
+ }
+ }
+
+ &__alert-container {
+ padding: 0 20px 15px;
+
+ &.policy-enforced {
+ padding: 0;
+
+ .AlertBanner {
+ padding-bottom: 8px;
+ border: none;
+ border-radius: 0;
+ border-bottom: 1px solid var(--center-channel-color-8, rgba(63, 67, 80, 0.08));
+ }
+
+ .TagGroup {
+ margin-top: 12px;
+ }
+ }
+ }
+
+ &__member {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding: 8px 16px;
+ border-radius: 4px;
+
+ &:hover {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ color: rgba(var(--center-channel-color-rgb), 0.75);
+
+ .channel-members-rhs__send-message {
+ display: block;
+ flex: 0 0 auto;
+ }
+ }
+
+ .ProfileSpan {
+ display: flex;
+ width: 100%;
+ flex-direction: row;
+ align-items: center;
+ padding: 4px 0; // This padding is to make sure the status icon doesn't get clipped off because of the overflow
+
+ .profileSpan_userInfo {
+ display: flex;
+ overflow: hidden;
+ flex-grow: 1;
+ cursor: pointer;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+
+ .MenuWrapper {
+ font-size: 11px;
+ font-weight: 600;
+ }
+ }
+
+ &__avatar {
+ flex-basis: fit-content;
+ flex-shrink: 0;
+ }
+
+ &__display-name {
+ display: inline;
+ overflow: hidden;
+ margin-left: 8px;
+ color: var(--center-channel-color);
+ font-size: 14px;
+ gap: 8px;
+ line-height: 20px;
+ text-overflow: ellipsis;
+ }
+
+ &__username {
+ margin-left: 4px;
+ color: rgba(var(--center-channel-color-rgb), 0.75);
+ font-size: 12px;
+ line-height: 18px;
+ }
+
+ &__send-message {
+ display: none;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 0;
+ border-radius: 4px;
+ margin-left: 8px;
+ background-color: transparent;
+
+ &:hover {
+ background-color: rgba(var(--center-channel-color-rgb), 0.12);
+ }
+
+ .icon {
+ color: rgba(var(--center-channel-color-rgb), 0.64);
+ font-size: 14.4px;
+ }
+ }
+
+ &__role-chooser {
+ display: none;
+ flex-basis: fit-content;
+ flex-shrink: 0;
+
+ &.editing {
+ display: block;
+ }
+
+ .MenuWrapper {
+ padding: 6px 10px;
+ border-radius: 4px;
+ &.MenuWrapper--open {
+ background: rgba(var(--button-bg-rgb), 0.16);
+ }
+ &:not(.MenuWrapper--open):hover {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ }
+ }
+ }
+
+ &__shared-icon {
+ margin: 0 0 0 4px;
+ font-size: 16px;
+ line-height: 20px;
+ }
+}
diff --git a/webapp/channels/src/components/channel_members_rhs/channel_members_rhs.test.tsx b/webapp/channels/src/components/channel_members_rhs/channel_members_rhs.test.tsx
new file mode 100644
index 0000000000..d4f052c7d6
--- /dev/null
+++ b/webapp/channels/src/components/channel_members_rhs/channel_members_rhs.test.tsx
@@ -0,0 +1,230 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+import type {ChannelType} from '@mattermost/types/channels';
+
+import {renderWithContext, screen} from 'tests/react_testing_utils';
+import Constants from 'utils/constants';
+
+import ChannelMembersRHS from './channel_members_rhs';
+
+// Mock the Redux connected component
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ connect: () => (Component: React.ComponentType) => Component,
+}));
+
+jest.mock('./member_list', () => {
+ const ListItemType = {
+ Member: 'member',
+ FirstSeparator: 'first-separator',
+ Separator: 'separator',
+ };
+
+ const mockComponent = jest.fn(() => {'Member List Mock'}
);
+ return Object.assign(mockComponent, {
+ ListItemType,
+ });
+});
+
+jest.mock('./header', () => {
+ return jest.fn(() => {'Header Mock'}
);
+});
+
+jest.mock('./action_bar', () => {
+ return jest.fn(() => {'Action Bar Mock'}
);
+});
+
+jest.mock('./search', () => {
+ return jest.fn(() => {'Search Bar Mock'}
);
+});
+
+// Mock the useAccessControlAttributes hook
+jest.mock('components/common/hooks/useAccessControlAttributes', () => {
+ // Define the EntityType enum in the mock
+ const EntityType = {
+ Channel: 'channel',
+ };
+
+ const mockHook = jest.fn(() => ({
+ attributeTags: ['tag1', 'tag2'],
+ structuredAttributes: [
+ {
+ name: 'attribute1',
+ values: ['tag1', 'tag2'],
+ },
+ ],
+ loading: false,
+ error: null,
+ fetchAttributes: jest.fn(),
+ }));
+
+ // Export both the default export (the hook) and the named export (EntityType)
+ return {
+ __esModule: true,
+ default: mockHook,
+ EntityType,
+ };
+});
+
+describe('channel_members_rhs/channel_members_rhs', () => {
+ // Using 'as any' to bypass TypeScript errors in test data
+ const baseProps = {
+ channel: {
+ id: 'channel_id',
+ name: 'channel-name',
+ display_name: 'Channel Name',
+ type: 'O' as ChannelType,
+ team_id: 'team_id',
+ group_constrained: false,
+ },
+ currentUserIsChannelAdmin: true,
+ membersCount: 3,
+ searchTerms: '',
+ canGoBack: false,
+ teamUrl: '/team',
+ channelMembers: [
+ {
+ user: {
+ id: 'user1',
+ username: 'user1',
+ email: 'user1@example.com',
+ first_name: 'User',
+ last_name: 'One',
+ },
+ membership: {
+ user_id: 'user1',
+ channel_id: 'channel_id',
+ scheme_admin: true,
+ scheme_user: true,
+ },
+ displayName: 'User One',
+ },
+ {
+ user: {
+ id: 'user2',
+ username: 'user2',
+ email: 'user2@example.com',
+ first_name: 'User',
+ last_name: 'Two',
+ },
+ membership: {
+ user_id: 'user2',
+ channel_id: 'channel_id',
+ scheme_admin: false,
+ scheme_user: true,
+ },
+ displayName: 'User Two',
+ },
+ ],
+ canManageMembers: true,
+ editing: false,
+ actions: {
+ openModal: jest.fn(),
+ openDirectChannelToUserId: jest.fn().mockResolvedValue({data: {}}),
+ closeRightHandSide: jest.fn(),
+ goBack: jest.fn(),
+ setChannelMembersRhsSearchTerm: jest.fn(),
+ loadProfilesAndReloadChannelMembers: jest.fn(),
+ loadMyChannelMemberAndRole: jest.fn(),
+ setEditChannelMembers: jest.fn(),
+ searchProfilesAndChannelMembers: jest.fn().mockResolvedValue({data: []}),
+ },
+ };
+
+ test('should render correctly', () => {
+ renderWithContext(
+ ,
+ );
+
+ // Check that the main components are rendered
+ expect(screen.getByTestId('header')).toBeInTheDocument();
+ expect(screen.getByTestId('action-bar')).toBeInTheDocument();
+ expect(screen.getByTestId('member-list')).toBeInTheDocument();
+ });
+
+ test('should show search bar when there are more than 20 members', () => {
+ const props = {
+ ...baseProps,
+ membersCount: 25,
+ };
+
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.getByTestId('search-bar')).toBeInTheDocument();
+ });
+
+ test('should show search bar when search terms are present', () => {
+ const props = {
+ ...baseProps,
+ searchTerms: 'test',
+ };
+
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.getByTestId('search-bar')).toBeInTheDocument();
+ });
+
+ test('should not show search bar when there are less than 20 members and no search terms', () => {
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.queryByTestId('search-bar')).not.toBeInTheDocument();
+ });
+
+ test('should show alert banner for default channel when editing and not channel admin', () => {
+ const props = {
+ ...baseProps,
+ channel: {
+ ...baseProps.channel,
+ name: Constants.DEFAULT_CHANNEL,
+ },
+ currentUserIsChannelAdmin: false,
+ editing: true,
+ };
+
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.getByText(/In this channel, you can only remove guests/)).toBeInTheDocument();
+ expect(screen.getByText(/channel admins/)).toBeInTheDocument();
+ });
+
+ test('should show alert banner for policy-enforced channels', () => {
+ const props = {
+ ...baseProps,
+ channel: {
+ ...baseProps.channel,
+ policy_enforced: true,
+ },
+ };
+
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.getByText('Channel access is restricted by user attributes')).toBeInTheDocument();
+ expect(screen.getByText('tag1')).toBeInTheDocument();
+ expect(screen.getByText('tag2')).toBeInTheDocument();
+ });
+});
diff --git a/webapp/channels/src/components/channel_members_rhs/channel_members_rhs.tsx b/webapp/channels/src/components/channel_members_rhs/channel_members_rhs.tsx
index 56fce3e190..027f01ce2d 100644
--- a/webapp/channels/src/components/channel_members_rhs/channel_members_rhs.tsx
+++ b/webapp/channels/src/components/channel_members_rhs/channel_members_rhs.tsx
@@ -5,17 +5,19 @@ import debounce from 'lodash/debounce';
import React, {useCallback, useEffect, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useHistory} from 'react-router-dom';
-import styled from 'styled-components';
-import type {Channel, ChannelMembership} from '@mattermost/types/channels';
+import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import {ProfilesInChannelSortBy} from 'mattermost-redux/actions/users';
import AlertBanner from 'components/alert_banner';
import ChannelInviteModal from 'components/channel_invite_modal';
+import useAccessControlAttributes, {EntityType} from 'components/common/hooks/useAccessControlAttributes';
import ExternalLink from 'components/external_link';
import MoreDirectChannels from 'components/more_direct_channels';
+import AlertTag from 'components/widgets/tag/alert_tag';
+import TagGroup from 'components/widgets/tag/tag_group';
import Constants, {ModalIdentifiers} from 'utils/constants';
@@ -23,21 +25,13 @@ import type {ModalData} from 'types/actions';
import ActionBar from './action_bar';
import Header from './header';
-import MemberList from './member_list';
+import MemberList, {ListItemType} from './member_list';
+import type {ChannelMember, ListItem} from './member_list';
import SearchBar from './search';
-const USERS_PER_PAGE = 100;
-export interface ChannelMember {
- user: UserProfile;
- membership?: ChannelMembership;
- status?: string;
- displayName: string;
-}
+import './channel_members_rhs.scss';
-const MembersContainer = styled.div`
- flex: 1 1 auto;
- padding: 0 4px 16px;
-`;
+const USERS_PER_PAGE = 100;
export interface Props {
channel: Channel;
@@ -63,17 +57,6 @@ export interface Props {
};
}
-export enum ListItemType {
- Member = 'member',
- FirstSeparator = 'first-separator',
- Separator = 'separator',
-}
-
-export interface ListItem {
- type: ListItemType;
- data: ChannelMember | JSX.Element;
-}
-
export default function ChannelMembersRHS({
channel,
currentUserIsChannelAdmin,
@@ -94,6 +77,21 @@ export default function ChannelMembersRHS({
const [isNextPageLoading, setIsNextPageLoading] = useState(false);
const {formatMessage} = useIntl();
+ const {structuredAttributes, loading} = useAccessControlAttributes(
+ EntityType.Channel,
+ channel.id,
+ 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());
+ };
+
const searching = searchTerms !== '';
const isDefaultChannel = channel.name === Constants.DEFAULT_CHANNEL;
@@ -134,17 +132,17 @@ export default function ChannelMembersRHS({
listcp.push({
type: ListItemType.FirstSeparator,
- data: {text},
+ data: {text}
,
});
} else if (!memberDone && member.membership?.scheme_admin === false) {
listcp.push({
type: ListItemType.Separator,
- data:
+ data:
- ,
+
,
});
memberDone = true;
}
@@ -226,7 +224,7 @@ export default function ChannelMembersRHS({
return (
+ {/* Show banner for policy-enforced channels */}
+ {channel.policy_enforced && (
+
+
+ {structuredAttributes.length > 0 && (
+
+ {structuredAttributes.flatMap((attribute) =>
+ attribute.values.map((value) => (
+
+ )),
+ )}
+
+ )}
+ {loading && {'Loading...'}}
+
+
+ )}
+
)}
{showSearch && (
@@ -278,7 +304,7 @@ export default function ChannelMembersRHS({
/>
)}
-
+
{channelMembers.length > 0 && (
)}
-
+
);
}
-
-const MemberListSeparator = styled.div`
- font-weight: 600;
- font-size: 12px;
- line-height: 28px;
- letter-spacing: 0.02em;
- text-transform: uppercase;
- padding: 0px 12px;
- color: rgba(var(--center-channel-color-rgb), 0.75);
- margin-top: 16px;
-`;
-
-const FirstMemberListSeparator = styled(MemberListSeparator)`
- margin-top: 0px;
-`;
-
-const AlertContainer = styled.div`
- padding: 0 20px 15px;
-`;
diff --git a/webapp/channels/src/components/channel_members_rhs/index.ts b/webapp/channels/src/components/channel_members_rhs/index.ts
index e497d2ed82..cca05d3364 100644
--- a/webapp/channels/src/components/channel_members_rhs/index.ts
+++ b/webapp/channels/src/components/channel_members_rhs/index.ts
@@ -39,8 +39,9 @@ import {Constants, RHSStates} from 'utils/constants';
import type {GlobalState} from 'types/store';
-import RHS from './channel_members_rhs';
-import type {Props, ChannelMember} from './channel_members_rhs';
+import ChannelMembersRHS from './channel_members_rhs';
+import type {Props} from './channel_members_rhs';
+import type {ChannelMember} from './member_list';
const buildProfileList = (
profilesInCurrentChannel: UserProfile[],
@@ -172,4 +173,4 @@ function mapDispatchToProps(dispatch: Dispatch) {
};
}
-export default connect(mapStateToProps, mapDispatchToProps)(RHS);
+export default connect(mapStateToProps, mapDispatchToProps)(ChannelMembersRHS);
diff --git a/webapp/channels/src/components/channel_members_rhs/member.tsx b/webapp/channels/src/components/channel_members_rhs/member.tsx
index 78aa6f6992..bd9a601aae 100644
--- a/webapp/channels/src/components/channel_members_rhs/member.tsx
+++ b/webapp/channels/src/components/channel_members_rhs/member.tsx
@@ -4,7 +4,6 @@
import classNames from 'classnames';
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
-import styled from 'styled-components';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
@@ -20,82 +19,11 @@ import SharedChannelIndicator from 'components/shared_channel_indicator';
import GuestTag from 'components/widgets/tag/guest_tag';
import WithTooltip from 'components/with_tooltip';
-import type {ChannelMember} from './channel_members_rhs';
-
-const Avatar = styled.div`
- flex-basis: fit-content;
- flex-shrink: 0;
-`;
-
-const DisplayName = styled.span`
- display: inline;
- overflow: hidden;
- margin-left: 8px;
- color: var(--center-channel-color);
- font-size: 14px;
- gap: 8px;
- line-height: 20px;
- text-overflow: ellipsis;
-`;
-
-const Username = styled.span`
- margin-left: 4px;
- color: rgba(var(--center-channel-color-rgb), 0.75);
- font-size: 12px;
- line-height: 18px;
-`;
-
-const SendMessage = styled.button`
- display: none;
- width: 24px;
- height: 24px;
- padding: 0;
- border: 0;
- margin-left: 8px;
- background-color: transparent;
- border-radius: 4px;
-
- &:hover {
- background-color: rgba(var(--center-channel-color-rgb), 0.12);
- }
-
- .icon {
- color: rgba(var(--center-channel-color-rgb), 0.64);
- font-size: 14.4px;
- };
-`;
-
-const RoleChooser = styled.div`
- display: none;
- flex-basis: fit-content;
- flex-shrink: 0;
-
- &.editing {
- display: block;
- }
-
- .MenuWrapper {
- padding: 6px 10px;
- border-radius: 4px;
- &.MenuWrapper--open {
- background: rgba(var(--button-bg-rgb), 0.16);
- }
- &:not(.MenuWrapper--open):hover {
- background: rgba(var(--center-channel-color-rgb), 0.08);
- }
- }
-`;
-
-const SharedIcon = styled.span`
- margin: 0 0 0 4px;
- font-size: 16px;
- line-height: 20px;
-`;
+import type {ChannelMember as ChannelMemberType} from './member_list';
interface Props {
- className?: string;
channel: Channel;
- member: ChannelMember;
+ member: ChannelMemberType;
index: number;
totalUsers: number;
editing: boolean;
@@ -104,19 +32,19 @@ interface Props {
};
}
-const Member = ({className, channel, member, index, totalUsers, editing, actions}: Props) => {
+const Member = ({channel, member, index, totalUsers, editing, actions}: Props) => {
const {formatMessage} = useIntl();
const userProfileSrc = Client4.getProfilePictureUrl(member.user.id, member.user.last_picture_update);
return (
-
+
-
+
{member.displayName}
{isGuest(member.user.roles) && }
{member.user.remote_id &&
(
-
+
-
+
)}
-
+
{
- member.displayName === member.user.username ? null :
{'@'}{member.user.username}
+ member.displayName === member.user.username ? null :
{'@'}{member.user.username}
}
-
{member.membership && (
@@ -195,7 +123,7 @@ const Member = ({className, channel, member, index, totalUsers, editing, actions
}
/>
)}
-
+
{!editing && (
- actions.openDirectMessage(member.user)}>
+
+
)}
);
};
-export default styled(Member)`
- display: flex;
- flex-direction: row;
- align-items: center;
- padding: 8px 16px;
- border-radius: 4px;
-
- &:hover {
- background: rgba(var(--center-channel-color-rgb), 0.08);
- color: rgba(var(--center-channel-color-rgb), 0.75);
-
- ${SendMessage} {
- display: block;
- flex: 0 0 auto;
- }
- }
-
- .ProfileSpan {
- width: 100%;
- display: flex;
- flex-direction: row;
- align-items: center;
- padding: 4px 0; // This padding is to make sure the status icon doesn't get clipped off because of the overflow
-
- .profileSpan_userInfo {
- display: flex;
- flex-grow: 1;
- cursor: pointer;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
-
- .MenuWrapper {
- font-size: 11px;
- font-weight: 600;
- }
-`;
+export default Member;
diff --git a/webapp/channels/src/components/channel_members_rhs/member_list.tsx b/webapp/channels/src/components/channel_members_rhs/member_list.tsx
index b52acdc161..8339a54a21 100644
--- a/webapp/channels/src/components/channel_members_rhs/member_list.tsx
+++ b/webapp/channels/src/components/channel_members_rhs/member_list.tsx
@@ -12,20 +12,20 @@ import type {UserProfile} from '@mattermost/types/users';
import Member from './member';
-interface ChannelMember {
+export interface ChannelMember {
user: UserProfile;
membership?: ChannelMembership;
status?: string;
displayName: string;
}
-enum ListItemType {
+export enum ListItemType {
Member = 'member',
FirstSeparator = 'first-separator',
Separator = 'separator',
}
-interface ListItem {
+export interface ListItem {
type: ListItemType;
data: ChannelMember | JSX.Element;
}
diff --git a/webapp/channels/src/components/common/hooks/useAccessControlAttributes.test.tsx b/webapp/channels/src/components/common/hooks/useAccessControlAttributes.test.tsx
new file mode 100644
index 0000000000..edaa11d538
--- /dev/null
+++ b/webapp/channels/src/components/common/hooks/useAccessControlAttributes.test.tsx
@@ -0,0 +1,211 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {renderHook, act} from '@testing-library/react-hooks';
+import React from 'react';
+import {Provider} from 'react-redux';
+import configureStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+
+import {useAccessControlAttributes, EntityType} from './useAccessControlAttributes';
+
+// Mock the getChannelAccessControlAttributes action
+jest.mock('mattermost-redux/actions/channels', () => {
+ const mockFn = jest.fn();
+
+ // Default implementation
+ mockFn.mockImplementation((channelId) => {
+ if (channelId === 'error-channel') {
+ return () => Promise.resolve({error: new Error('Failed to fetch attributes')});
+ }
+
+ if (channelId === 'unsupported') {
+ throw new Error('Unsupported entity type: unsupported');
+ }
+
+ return () => Promise.resolve({
+ data: {
+ department: ['engineering', 'marketing'],
+ location: ['remote'],
+ },
+ });
+ });
+
+ return {
+ getChannelAccessControlAttributes: mockFn,
+ };
+});
+
+describe('useAccessControlAttributes', () => {
+ const mockStore = configureStore([thunk]);
+ const initialState = {
+ entities: {
+ channels: {
+ channels: {
+ 'channel-1': {
+ id: 'channel-1',
+ policy_enforced: true,
+ },
+ },
+ },
+ },
+ };
+ const store = mockStore(initialState);
+
+ // Helper function to wrap the hook with the Redux provider
+ const wrapper = ({children}: {children: React.ReactNode}) => (
+
+ {children}
+
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('should return initial state', () => {
+ const {result} = renderHook(() => useAccessControlAttributes(EntityType.Channel, undefined, undefined), {wrapper});
+
+ expect(result.current.attributeTags).toEqual([]);
+ expect(result.current.structuredAttributes).toEqual([]);
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ test('should not fetch attributes if entityId is undefined', async () => {
+ const {result} = renderHook(() => useAccessControlAttributes(EntityType.Channel, undefined, true), {wrapper});
+
+ await act(async () => {
+ await result.current.fetchAttributes();
+ });
+
+ expect(result.current.attributeTags).toEqual([]);
+ expect(result.current.structuredAttributes).toEqual([]);
+ expect(result.current.loading).toBe(false);
+ });
+
+ test('should not fetch attributes if hasAccessControl is false', async () => {
+ const {result} = renderHook(() => useAccessControlAttributes(EntityType.Channel, 'channel-1', false), {wrapper});
+
+ await act(async () => {
+ await result.current.fetchAttributes();
+ });
+
+ expect(result.current.attributeTags).toEqual([]);
+ expect(result.current.structuredAttributes).toEqual([]);
+ expect(result.current.loading).toBe(false);
+ });
+
+ test('should fetch and process attributes successfully', async () => {
+ const {result, waitForNextUpdate} = renderHook(() => useAccessControlAttributes(EntityType.Channel, 'channel-1', true), {wrapper});
+
+ // Initial state
+ expect(result.current.loading).toBe(true);
+
+ // Wait for the hook to finish fetching
+ await waitForNextUpdate();
+
+ // Check the final state
+ expect(result.current.attributeTags).toEqual(['engineering', 'marketing', 'remote']);
+ expect(result.current.structuredAttributes).toEqual([
+ {name: 'department', values: ['engineering', 'marketing']},
+ {name: 'location', values: ['remote']},
+ ]);
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ test('should handle errors when fetching attributes', async () => {
+ const {result, waitForNextUpdate} = renderHook(() => useAccessControlAttributes(EntityType.Channel, 'error-channel', true), {wrapper});
+
+ // Initial state
+ expect(result.current.loading).toBe(true);
+
+ // Wait for the hook to finish fetching
+ await waitForNextUpdate();
+
+ // Check the final state
+ expect(result.current.attributeTags).toEqual([]);
+ expect(result.current.structuredAttributes).toEqual([]);
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeInstanceOf(Error);
+ expect(result.current.error?.message).toBe('Failed to fetch attributes');
+ });
+
+ test('should handle unsupported entity types', async () => {
+ // @ts-expect-error - Testing invalid entity type
+ const {result} = renderHook(() => useAccessControlAttributes('unsupported', 'channel-1', true), {wrapper});
+
+ // Manually call fetchAttributes to trigger the error
+ await act(async () => {
+ // Set loading to true manually for the test
+ result.current.loading = true;
+ try {
+ await result.current.fetchAttributes();
+ } catch (error) {
+ // Ignore the error
+ }
+ });
+
+ // Check the final state
+ expect(result.current.attributeTags).toEqual([]);
+ expect(result.current.structuredAttributes).toEqual([]);
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeInstanceOf(Error);
+ expect(result.current.error?.message).toBe('Unsupported entity type: unsupported');
+ });
+
+ test('should use cached data if available and not expired', async () => {
+ // First call to populate the cache
+ const {result: result1} = renderHook(() => useAccessControlAttributes(EntityType.Channel, 'channel-1', true), {wrapper});
+
+ // Wait for the initial fetch to complete
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ });
+
+ // Verify the data was loaded
+ expect(result1.current.attributeTags).toEqual(['engineering', 'marketing', 'remote']);
+ expect(result1.current.loading).toBe(false);
+
+ // Reset the mock to track new calls
+ const getChannelAccessControlAttributes = require('mattermost-redux/actions/channels').getChannelAccessControlAttributes;
+ getChannelAccessControlAttributes.mockClear();
+
+ // Second call should use the cache
+ const {result: result2} = renderHook(() => useAccessControlAttributes(EntityType.Channel, 'channel-1', true), {wrapper});
+
+ // Wait for any async operations to complete
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ });
+
+ // Check that the data is the same
+ expect(result2.current.attributeTags).toEqual(result1.current.attributeTags);
+ expect(result2.current.structuredAttributes).toEqual(result1.current.structuredAttributes);
+
+ // The action should not have been called again
+ expect(getChannelAccessControlAttributes).not.toHaveBeenCalled();
+ });
+
+ test('should manually fetch attributes when fetchAttributes is called', async () => {
+ const {result} = renderHook(() => useAccessControlAttributes(EntityType.Channel, 'channel-1', true), {wrapper});
+
+ // Wait for the initial fetch to complete
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ // Reset the mock to track new calls
+ const getChannelAccessControlAttributes = require('mattermost-redux/actions/channels').getChannelAccessControlAttributes;
+ getChannelAccessControlAttributes.mockClear();
+
+ // Manually fetch attributes with forceRefresh=true to bypass cache
+ await act(async () => {
+ await result.current.fetchAttributes(true);
+ });
+
+ // The action should have been called again
+ expect(getChannelAccessControlAttributes).toHaveBeenCalledWith('channel-1');
+ });
+});
diff --git a/webapp/channels/src/components/common/hooks/useAccessControlAttributes.ts b/webapp/channels/src/components/common/hooks/useAccessControlAttributes.ts
new file mode 100644
index 0000000000..1b21e86694
--- /dev/null
+++ b/webapp/channels/src/components/common/hooks/useAccessControlAttributes.ts
@@ -0,0 +1,188 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useState, useEffect, useCallback} from 'react';
+import {useDispatch} from 'react-redux';
+
+import type {AccessControlAttribute} from '@mattermost/types/access_control';
+
+import {getChannelAccessControlAttributes} from 'mattermost-redux/actions/channels';
+
+// Define supported entity types
+export enum EntityType {
+ Channel = 'channel',
+
+ // 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)
+const attributesCache: Record = {};
+
+// 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);
+
+/**
+ * A hook for fetching access control attributes for an entity
+ *
+ * @param entityType - The type of entity (e.g., 'channel')
+ * @param entityId - The ID of the entity
+ * @param hasAccessControl - Whether the entity has access control enabled
+ * @returns An object containing the attribute tags, loading state, and fetch function
+ */
+export const useAccessControlAttributes = (
+ entityType: EntityType,
+ entityId: string | undefined,
+ hasAccessControl: boolean | undefined,
+) => {
+ const [attributeTags, setAttributeTags] = useState([]);
+ const [structuredAttributes, setStructuredAttributes] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const dispatch = useDispatch();
+
+ // Helper function to process attribute data and extract tags
+ const processAttributeData = useCallback((data: Record | 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 fetchAttributes = useCallback(async (forceRefresh = false) => {
+ if (!entityId || !hasAccessControl) {
+ 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
+ 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);
+ setLoading(false);
+ return;
+ }
+
+ // Handle different entity types
+ let result;
+ switch (entityType) {
+ case EntityType.Channel:
+ result = await dispatch(getChannelAccessControlAttributes(entityId));
+ break;
+ default:
+ // defensive programming: if we add new entity types, we should handle them here
+ 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([]);
+ }
+ } catch (err) {
+ setError(err as Error);
+ setAttributeTags([]);
+ setStructuredAttributes([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [entityType, entityId, hasAccessControl, processAttributeData]);
+
+ // Fetch attributes when the component mounts or when dependencies change
+ useEffect(() => {
+ fetchAttributes();
+ }, [fetchAttributes]);
+
+ return {
+ attributeTags,
+ structuredAttributes,
+ loading,
+ error,
+ fetchAttributes,
+ };
+};
+
+export default useAccessControlAttributes;
diff --git a/webapp/channels/src/components/widgets/tag/__snapshots__/alert_tag.test.tsx.snap b/webapp/channels/src/components/widgets/tag/__snapshots__/alert_tag.test.tsx.snap
new file mode 100644
index 0000000000..56d8e0ac24
--- /dev/null
+++ b/webapp/channels/src/components/widgets/tag/__snapshots__/alert_tag.test.tsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`components/widgets/tag/AlertTag should match snapshot 1`] = `
+
+
+ Tag Text
+
+
+`;
diff --git a/webapp/channels/src/components/widgets/tag/alert_tag.scss b/webapp/channels/src/components/widgets/tag/alert_tag.scss
new file mode 100644
index 0000000000..a3a42b5c92
--- /dev/null
+++ b/webapp/channels/src/components/widgets/tag/alert_tag.scss
@@ -0,0 +1,71 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+.AlertTag {
+ display: inline-flex;
+ overflow: hidden;
+ max-width: 100%;
+ align-items: center;
+ border-radius: 4px;
+ margin-bottom: 8px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ // Reset button styles when using button element
+ &:is(button) {
+ padding: 0;
+ border: none;
+ background: none;
+ font: inherit;
+ outline: inherit;
+ }
+
+ &--clickable {
+ cursor: pointer;
+
+ &:hover {
+ opacity: 0.8;
+ }
+ }
+
+ // Variants
+ &--default {
+ background-color: rgba(var(--center-channel-color-rgb), 0.08);
+ color: var(--center-channel-color);
+ }
+
+ &--primary {
+ background-color: rgba(var(--button-bg-rgb), 0.08);
+ color: var(--button-bg);
+ }
+
+ &--secondary {
+ background-color: rgba(var(--button-color-rgb), 0.08);
+ color: var(--button-color);
+ }
+
+ &--info {
+ background-color: rgba(var(--info-color-rgb), 0.08);
+ color: var(--info-color);
+ }
+
+ // Sizes
+ &--small {
+ height: 16px;
+ padding: 0 6px;
+ font-size: 10px;
+ }
+
+ &--medium {
+ height: 20px;
+ padding: 2px 8px;
+ font-size: 12px;
+ }
+
+ &--large {
+ height: 24px;
+ padding: 4px 12px;
+ font-size: 14px;
+ }
+}
diff --git a/webapp/channels/src/components/widgets/tag/alert_tag.test.tsx b/webapp/channels/src/components/widgets/tag/alert_tag.test.tsx
new file mode 100644
index 0000000000..97a5cd6004
--- /dev/null
+++ b/webapp/channels/src/components/widgets/tag/alert_tag.test.tsx
@@ -0,0 +1,156 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {screen, render} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import AlertTag from './alert_tag';
+
+describe('components/widgets/tag/AlertTag', () => {
+ test('should match snapshot', () => {
+ const {container} = render(
+ ,
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ test('should render the text correctly', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Tag Text')).toBeInTheDocument();
+ });
+
+ test('should apply custom className', () => {
+ const {container} = render(
+ ,
+ );
+
+ expect(container.querySelector('.custom-class')).not.toBeNull();
+ });
+
+ test('should apply variant class', () => {
+ const {container} = render(
+ ,
+ );
+
+ expect(container.querySelector('.AlertTag--primary')).not.toBeNull();
+ });
+
+ test('should apply size class', () => {
+ const {container} = render(
+ ,
+ );
+
+ expect(container.querySelector('.AlertTag--small')).not.toBeNull();
+ });
+
+ test('should apply clickable class when onClick is provided', () => {
+ const onClick = jest.fn();
+ const {container} = render(
+ ,
+ );
+
+ expect(container.querySelector('.AlertTag--clickable')).not.toBeNull();
+ });
+
+ test('should call onClick when clicked', async () => {
+ const onClick = jest.fn();
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByText('Tag Text'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ test('should apply testId', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('test-id')).toBeInTheDocument();
+ });
+
+ test('should use span element when onClick is not provided', () => {
+ render(
+ ,
+ );
+
+ const element = screen.getByText('Tag Text');
+ expect(element.tagName).toBe('SPAN');
+ });
+
+ test('should use button element when onClick is provided', () => {
+ const onClick = jest.fn();
+ render(
+ ,
+ );
+
+ const element = screen.getByText('Tag Text');
+ expect(element.tagName).toBe('BUTTON');
+ });
+
+ test('should add type="button" attribute when onClick is provided', () => {
+ const onClick = jest.fn();
+ render(
+ ,
+ );
+
+ const element = screen.getByText('Tag Text');
+ expect(element.getAttribute('type')).toBe('button');
+ });
+
+ test('should render with tooltip when tooltipTitle is provided', () => {
+ // Note: We can't fully test the tooltip functionality here as it requires
+ // hovering which is more complex.
+ // This test just ensures the WithTooltip component is used.
+ const {container} = render(
+ ,
+ );
+
+ // The tag should still be rendered
+ expect(screen.getByText('Tag Text')).toBeInTheDocument();
+
+ // The structure should be different when tooltip is used
+ // (WithTooltip wraps the tag element)
+ const tagElement = screen.getByText('Tag Text');
+ expect(tagElement.parentElement?.parentElement).not.toBe(container);
+ });
+});
diff --git a/webapp/channels/src/components/widgets/tag/alert_tag.tsx b/webapp/channels/src/components/widgets/tag/alert_tag.tsx
new file mode 100644
index 0000000000..02cc134d7e
--- /dev/null
+++ b/webapp/channels/src/components/widgets/tag/alert_tag.tsx
@@ -0,0 +1,81 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import classNames from 'classnames';
+import React from 'react';
+import type {ReactNode} from 'react';
+
+import WithTooltip from 'components/with_tooltip';
+
+import './alert_tag.scss';
+
+export type AlertTagProps = {
+
+ text: string;
+
+ className?: string;
+
+ onClick?: (e: React.MouseEvent) => void;
+
+ variant?: 'default' | 'primary' | 'secondary' | 'info';
+
+ size?: 'small' | 'medium' | 'large';
+
+ testId?: string;
+
+ tooltipTitle?: string | ReactNode;
+};
+
+/**
+ * A tag component used for displaying alert information
+ * Optionally includes tooltip functionality when tooltipTitle is provided
+ */
+const AlertTag: React.FC = ({
+ text,
+ className,
+ onClick,
+ variant = 'default',
+ size = 'medium',
+ testId,
+ tooltipTitle,
+}) => {
+ // Determine which element to use based on whether onClick is provided
+ const TagElement = onClick ? 'button' : 'span';
+
+ const tagElement = (
+
+ {text}
+
+ );
+
+ // If tooltipTitle is provided, wrap the tag with WithTooltip
+ if (tooltipTitle) {
+ return (
+
+ {tagElement}
+
+ );
+ }
+
+ // Otherwise, just return the tag
+ return tagElement;
+};
+
+export default AlertTag;
diff --git a/webapp/channels/src/components/widgets/tag/tag_group.scss b/webapp/channels/src/components/widgets/tag/tag_group.scss
new file mode 100644
index 0000000000..8be0d19304
--- /dev/null
+++ b/webapp/channels/src/components/widgets/tag/tag_group.scss
@@ -0,0 +1,8 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+.TagGroup {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
diff --git a/webapp/channels/src/components/widgets/tag/tag_group.tsx b/webapp/channels/src/components/widgets/tag/tag_group.tsx
new file mode 100644
index 0000000000..a1460fa415
--- /dev/null
+++ b/webapp/channels/src/components/widgets/tag/tag_group.tsx
@@ -0,0 +1,36 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import classNames from 'classnames';
+import React from 'react';
+
+import './tag_group.scss';
+
+export type TagGroupProps = {
+
+ /**
+ * Child elements (typically Tag components)
+ */
+ children: React.ReactNode;
+
+ /**
+ * Optional CSS class name for custom styling
+ */
+ className?: string;
+};
+
+/**
+ * A component for grouping and displaying multiple tags
+ */
+const TagGroup: React.FC = ({
+ children,
+ className,
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default TagGroup;
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index f6067028af..e86dfca89d 100644
--- a/webapp/channels/src/i18n/en.json
+++ b/webapp/channels/src/i18n/en.json
@@ -3472,6 +3472,8 @@
"channel_invite.invite_team_members.messageOverflow": "You can add {firstUser} and {others} to this channel once they are members of the {team} team.",
"channel_invite.invite_team_members.title": "{count, plural, =1 {1 user was} other {# users were}} not selected because they are not a part of this team",
"channel_invite.no_options_message": "No matches found - Invite them to the team",
+ "channel_invite.policy_enforced.description": "Only people who match the specified access rules can be selected and added to this channel.",
+ "channel_invite.policy_enforced.title": "Channel access is restricted by user attributes",
"channel_loader.posted": "Posted",
"channel_loader.postedImage": " posted an image",
"channel_loader.socketError": "Please check connection, Mattermost unreachable. If issue persists, ask administrator to [check WebSocket port](!https://docs.mattermost.com/install/troubleshooting.html#please-check-connection-mattermost-unreachable-if-issue-persists-ask-administrator-to-check-websocket-port).",
@@ -3506,6 +3508,7 @@
"channel_members_rhs.member.select_role_channel_member": "Member",
"channel_members_rhs.member.select_role_guest": "Guest",
"channel_members_rhs.member.send_message": "Send message",
+ "channel_members_rhs.policy_enforced_restrictions": "Channel access is restricted by user attributes",
"channel_members_rhs.search_bar.aria.cancel_search_button": "cancel members search",
"channel_members_rhs.search_bar.placeholder": "Search members",
"channel_menu.bookmarks": "Bookmarks Bar",
diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts
index 0c6e389eda..943b301200 100644
--- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts
+++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts
@@ -4,6 +4,7 @@
import type {AnyAction} from 'redux';
import {batchActions} from 'redux-batched-actions';
+import type {AccessControlAttributes} from '@mattermost/types/access_control';
import type {
Channel,
ChannelNotifyProps,
@@ -1454,6 +1455,21 @@ export function fetchMissingChannels(channelIDs: string[]): ActionFuncAsync {
+ return async (dispatch, getState) => {
+ let data;
+ try {
+ data = await Client4.getChannelAccessControlAttributes(channelId);
+ } catch (error) {
+ forceLogoutIfNecessary(error, dispatch, getState);
+ dispatch(logError(error));
+ return {error};
+ }
+
+ return {data};
+ };
+}
+
export default {
selectChannel,
createChannel,
@@ -1483,4 +1499,5 @@ export default {
membersMinusGroupMembers,
getChannelModerations,
getChannelMemberCountsByGroup,
+ getChannelAccessControlAttributes,
};
diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts
index ee14c49171..8a8c850d38 100644
--- a/webapp/platform/client/src/client4.ts
+++ b/webapp/platform/client/src/client4.ts
@@ -3,7 +3,7 @@
/* eslint-disable max-lines */
-import type {AccessControlPolicy, CELExpressionError, AccessControlTestResult, AccessControlPoliciesResult, AccessControlPolicyChannelsResult, AccessControlVisualAST} from '@mattermost/types/access_control';
+import type {AccessControlPolicy, CELExpressionError, AccessControlTestResult, AccessControlPoliciesResult, AccessControlPolicyChannelsResult, AccessControlVisualAST, AccessControlAttributes} from '@mattermost/types/access_control';
import type {ClusterInfo, AnalyticsRow, SchemaMigration, LogFilterQuery} from '@mattermost/types/admin';
import type {AppBinding, AppCallRequest, AppCallResponse} from '@mattermost/types/apps';
import type {Audit} from '@mattermost/types/audits';
@@ -4512,6 +4512,13 @@ export default class Client4 {
{method: 'post', body: JSON.stringify({expression})},
);
};
+
+ getChannelAccessControlAttributes = (channelId: string) => {
+ return this.doFetch(
+ `${this.getChannelRoute(channelId)}/access_control/attributes`,
+ {method: 'get'},
+ );
+ };
}
export function parseAndMergeNestedHeaders(originalHeaders: any) {
diff --git a/webapp/platform/types/src/access_control.ts b/webapp/platform/types/src/access_control.ts
index 33b01496c5..4aa1b08640 100644
--- a/webapp/platform/types/src/access_control.ts
+++ b/webapp/platform/types/src/access_control.ts
@@ -53,11 +53,6 @@ export type AccessControlTestResult = {
total: number;
}
-export type AccessControlEntity = {
- name: string;
- attributes: AccessControlAttribute[];
-}
-
export type AccessControlAttribute = {
name: string;
values: string[];
@@ -73,3 +68,19 @@ export type AccessControlVisualASTNode = {
value: any;
value_type: number;
}
+
+/**
+ * Type definition for access control attributes
+ */
+export type AccessControlAttributes = Record;
+
+/**
+ * Interface for entities that can have access control
+ */
+export interface AccessControlled {
+
+ /**
+ * Whether access control is enforced for this entity
+ */
+ access_control_enforced?: boolean;
+}