From f9a92c1700fa0eeeae2005cc37c88f8a56b127bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Thu, 15 May 2025 15:25:19 +0200 Subject: [PATCH] Mm 63844 add end user indicators (#30971) MM-63844 - ABAC add end user indicators to channel manage users --- .../channel_invite_modal.test.tsx.snap | 362 ++++----- .../channel_invite_modal.scss | 45 ++ .../channel_invite_modal.test.tsx | 420 ++++++++-- .../channel_invite_modal.tsx | 730 +++++++++--------- .../channel_members_rhs.scss | 150 ++++ .../channel_members_rhs.test.tsx | 230 ++++++ .../channel_members_rhs.tsx | 111 +-- .../components/channel_members_rhs/index.ts | 7 +- .../components/channel_members_rhs/member.tsx | 147 +--- .../channel_members_rhs/member_list.tsx | 6 +- .../hooks/useAccessControlAttributes.test.tsx | 211 +++++ .../hooks/useAccessControlAttributes.ts | 188 +++++ .../tag/__snapshots__/alert_tag.test.tsx.snap | 11 + .../src/components/widgets/tag/alert_tag.scss | 71 ++ .../components/widgets/tag/alert_tag.test.tsx | 156 ++++ .../src/components/widgets/tag/alert_tag.tsx | 81 ++ .../src/components/widgets/tag/tag_group.scss | 8 + .../src/components/widgets/tag/tag_group.tsx | 36 + webapp/channels/src/i18n/en.json | 3 + .../mattermost-redux/src/actions/channels.ts | 17 + webapp/platform/client/src/client4.ts | 9 +- webapp/platform/types/src/access_control.ts | 21 +- 22 files changed, 2232 insertions(+), 788 deletions(-) create mode 100644 webapp/channels/src/components/channel_invite_modal/channel_invite_modal.scss create mode 100644 webapp/channels/src/components/channel_members_rhs/channel_members_rhs.scss create mode 100644 webapp/channels/src/components/channel_members_rhs/channel_members_rhs.test.tsx create mode 100644 webapp/channels/src/components/common/hooks/useAccessControlAttributes.test.tsx create mode 100644 webapp/channels/src/components/common/hooks/useAccessControlAttributes.ts create mode 100644 webapp/channels/src/components/widgets/tag/__snapshots__/alert_tag.test.tsx.snap create mode 100644 webapp/channels/src/components/widgets/tag/alert_tag.scss create mode 100644 webapp/channels/src/components/widgets/tag/alert_tag.test.tsx create mode 100644 webapp/channels/src/components/widgets/tag/alert_tag.tsx create mode 100644 webapp/channels/src/components/widgets/tag/tag_group.scss create mode 100644 webapp/channels/src/components/widgets/tag/tag_group.tsx 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; +}