Mm 63844 add end user indicators (#30971)

MM-63844 - ABAC add end user indicators to channel manage users
Этот коммит содержится в:
Pablo Vélez
2025-05-15 15:25:19 +02:00
коммит произвёл GitHub
родитель fbdecd806c
Коммит f9a92c1700
22 изменённых файлов: 2232 добавлений и 788 удалений

Просмотреть файл

@@ -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`] = `
<Modal
animation={true}
aria-labelledby="channelInviteModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal channel-invite"
dialogComponentClass={[Function]}
enforceFocus={true}
<GenericModal
bodyOverflowVisible={true}
className="channel-invite"
compassDesign={true}
id="addUsersToChannelModal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
modalHeaderText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Add people to {channel}"
id="channel_invite.addNewMembers"
values={
Object {
"channel": "testing",
}
}
/>
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
id="channelInviteModalLabel"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="deletePostModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Add people to {channel}"
id="channel_invite.addNewMembers"
values={
Object {
"channel": "testing",
}
}
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
className="overflow--visible"
componentClass="div"
role="application"
<div
className="channel-invite__wrapper"
>
<div
className="channel-invite__content"
@@ -158,68 +123,33 @@ exports[`components/channel_invite_modal should match snapshot for channel_invit
users={Array []}
/>
</div>
</ModalBody>
</Modal>
</div>
</GenericModal>
`;
exports[`components/channel_invite_modal should match snapshot for channel_invite_modal with profiles from DMs 1`] = `
<Modal
animation={true}
aria-labelledby="channelInviteModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal channel-invite"
dialogComponentClass={[Function]}
enforceFocus={true}
<GenericModal
bodyOverflowVisible={true}
className="channel-invite"
compassDesign={true}
id="addUsersToChannelModal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
modalHeaderText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Add people to {channel}"
id="channel_invite.addNewMembers"
values={
Object {
"channel": "testing",
}
}
/>
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
id="channelInviteModalLabel"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="deletePostModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Add people to {channel}"
id="channel_invite.addNewMembers"
values={
Object {
"channel": "testing",
}
}
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
className="overflow--visible"
componentClass="div"
role="application"
<div
className="channel-invite__wrapper"
>
<div
className="channel-invite__content"
@@ -320,90 +250,33 @@ exports[`components/channel_invite_modal should match snapshot for channel_invit
users={Array []}
/>
</div>
</ModalBody>
</Modal>
</div>
</GenericModal>
`;
exports[`components/channel_invite_modal should match snapshot for channel_invite_modal with userStatuses 1`] = `
<Memo(GroupOption)
addUserProfile={[MockFunction]}
group={
Object {
"delete_at": 0,
"id": "user-1",
"label": "user-1",
"value": "user-1",
}
}
isSelected={true}
onMouseMove={[MockFunction]}
rowSelected="more-modal__row--selected"
selectedItemRef={
Object {
"current": null,
}
}
/>
`;
exports[`components/channel_invite_modal should match snapshot with exclude and include users 1`] = `
<Modal
animation={true}
aria-labelledby="channelInviteModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal channel-invite"
dialogComponentClass={[Function]}
enforceFocus={true}
<GenericModal
bodyOverflowVisible={true}
className="channel-invite"
compassDesign={true}
id="addUsersToChannelModal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
modalHeaderText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Add people to {channel}"
id="channel_invite.addNewMembers"
values={
Object {
"channel": "testing",
}
}
/>
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
id="channelInviteModalLabel"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="deletePostModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Add people to {channel}"
id="channel_invite.addNewMembers"
values={
Object {
"channel": "testing",
}
}
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
className="overflow--visible"
componentClass="div"
role="application"
<div
className="channel-invite__wrapper"
>
<div
className="channel-invite__content"
@@ -504,6 +377,133 @@ exports[`components/channel_invite_modal should match snapshot with exclude and
users={Array []}
/>
</div>
</ModalBody>
</Modal>
</div>
</GenericModal>
`;
exports[`components/channel_invite_modal should match snapshot with exclude and include users 1`] = `
<GenericModal
bodyOverflowVisible={true}
className="channel-invite"
compassDesign={true}
id="addUsersToChannelModal"
modalHeaderText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Add people to {channel}"
id="channel_invite.addNewMembers"
values={
Object {
"channel": "testing",
}
}
/>
}
onExited={[MockFunction]}
onHide={[Function]}
show={true}
>
<div
className="channel-invite__wrapper"
>
<div
className="channel-invite__content"
>
<MultiSelect
ariaLabelRenderer={[Function]}
backButtonClass="btn-tertiary tertiary-button"
backButtonClick={[Function]}
backButtonText={
Object {
"defaultMessage": "Cancel",
"id": "multiselect.cancel",
}
}
buttonSubmitLoadingText={
Object {
"defaultMessage": "Adding...",
"id": "multiselect.adding",
}
}
buttonSubmitText={
Object {
"defaultMessage": "Add",
"id": "multiselect.add",
}
}
customNoOptionsMessage={null}
focusOnLoad={true}
handleAdd={[Function]}
handleCancel={[Function]}
handleDelete={[Function]}
handleInput={[Function]}
handlePageChange={[Function]}
handleSubmit={[Function]}
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
"formatDisplayName": [Function],
"formatList": [Function],
"formatListToParts": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatNumberToParts": [Function],
"formatPlural": [Function],
"formatRelativeTime": [Function],
"formatTime": [Function],
"formatTimeToParts": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getDisplayNames": [Function],
"getListFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralRules": [Function],
"getRelativeTimeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
}
}
key="addUsersToChannelKey"
loading={true}
optionRenderer={[Function]}
options={Array []}
perPage={50}
placeholderText={
Object {
"defaultMessage": "Search for people or groups",
"id": "multiselect.placeholder.peopleOrGroups",
}
}
saveButtonPosition="bottom"
saving={false}
savingEnabled={true}
selectedItemRef={
Object {
"current": null,
}
}
valueWithImage={true}
values={Array []}
/>
<Memo(TeamWarningBanner)
guests={Array []}
teamId="eatxocwc3bg9ffo9xyybnj4omr"
users={Array []}
/>
</div>
</div>
</GenericModal>
`;

Просмотреть файл

@@ -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;
}
}
}

Просмотреть файл

@@ -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(
<ChannelInviteModal {...baseProps}/>,
);
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(
<ChannelInviteModal {...props}/>,
);
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(
<ChannelInviteModal
{...baseProps}
/>,
);
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(
<ChannelInviteModal
{...props}
/>,
);
await act(async () => {
const {getByText} = renderWithContext(
<ChannelInviteModal
{...props}
/>,
);
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(
<ChannelInviteModal
{...props}
/>,
);
// 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(
<ChannelInviteModal
{...props}
/>,
);
await act(async () => {
const {getByText} = renderWithContext(
<ChannelInviteModal
{...props}
/>,
);
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(
<ChannelInviteModal {...baseProps}/>,
);
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(
<ChannelInviteModal
{...props}
/>,
);
// 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(
<ChannelInviteModal {...props}/>,
);
// 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(
<ChannelInviteModal {...props}/>,
);
// 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(
<ChannelInviteModal {...props}/>,
);
// 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(
<ChannelInviteModal {...props}/>,
);
// 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(
<ChannelInviteModal {...props}/>,
);
// 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);
});
});

Просмотреть файл

@@ -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<UserProfileValue | GroupValue>;
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<UserProfileValue[]>([]);
const [usersNotInTeam, setUsersNotInTeam] = useState<UserProfileValue[]>([]);
const [guestsNotInTeam, setGuestsNotInTeam] = useState<UserProfileValue[]>([]);
const [term, setTerm] = useState('');
const [show, setShow] = useState(true);
const [saving, setSaving] = useState(false);
const [loadingUsers, setLoadingUsers] = useState(true);
const [groupAndUserOptions, setGroupAndUserOptions] = useState<Array<UserProfileValue | GroupValue>>([]);
const [inviteError, setInviteError] = useState<string | undefined>(undefined);
const UserMappingSpan = styled.span`
position: absolute;
right: 20px;
`;
const searchTimeoutId = useRef<number>(0);
const selectedItemRef = useRef<HTMLDivElement>(null);
export class ChannelInviteModal extends React.PureComponent<Props, State> {
private searchTimeoutId = 0;
private selectedItemRef = React.createRef<HTMLDivElement>();
// 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<string> => {
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<string>): 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<Props, State> {
].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<UserProfileValue | GroupValue>): void => {
// Handle delete (removing users from selection)
const handleDelete = useCallback((values: Array<UserProfileValue | GroupValue>) => {
// 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<Props, State> {
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<string>): 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<string, string> = {};
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 (
<div
key={option.id}
ref={isSelected ? this.selectedItemRef : option.id}
ref={isSelected ? selectedItemRef : undefined}
className={'more-modal__row clickable ' + rowSelected}
onClick={() => onAdd(option)}
onMouseMove={() => onMouseMove(option)}
>
<ProfilePicture
src={Client4.getProfilePictureUrl(option.id, option.last_picture_update)}
status={this.props.userStatuses[option.id]}
status={props.userStatuses[option.id]}
size='md'
username={option.username}
/>
@@ -408,15 +362,15 @@ export class ChannelInviteModal extends React.PureComponent<Props, State> {
{displayName}
{option.is_bot && <BotTag/>}
{isGuest(option.roles) && <GuestTag className='popoverlist'/>}
{displayName === option.username ? null : <UsernameSpan className='ml-2 light'>
{displayName === option.username ? null : <span className='channel-invite__username ml-2 light'>
{'@'}{option.username}
</UsernameSpan>
</span>
}
<UserMappingSpan
className='light'
<span
className='channel-invite__user-mapping light'
>
{userMapping[option.id]}
</UserMappingSpan>
</span>
</span>
</div>
</div>
@@ -442,146 +396,226 @@ export class ChannelInviteModal extends React.PureComponent<Props, State> {
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 = (<label className='has-error control-label'>{this.state.inviteError}</label>);
// 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 (
<ToggleModalButton
className={`${props.inviteAsGuest ? 'invite-as-guest' : ''} btn btn-link`}
modalId={ModalIdentifiers.INVITATION}
dialogType={InvitationModal}
dialogProps={{
channelToInvite: this.props.channel,
initialValue: this.state.term,
inviteAsGuest: props.inviteAsGuest,
focusOriginElement: 'customNoOptionsMessageLink',
}}
onClick={closeMembersInviteModal}
id={props.id}
>
{props.children}
</ToggleModalButton>
);
};
// Render the component
const buttonSubmitText = defineMessage({id: 'multiselect.add', defaultMessage: 'Add'});
const buttonSubmitLoadingText = defineMessage({id: 'multiselect.adding', defaultMessage: 'Adding...'});
const customNoOptionsMessage = (
<div
className='custom-no-options-message'
>
<FormattedMessage
id='channel_invite.no_options_message'
defaultMessage='No matches found - <InvitationModalLink>Invite them to the team</InvitationModalLink>'
values={{
InvitationModalLink: (chunks: string) => (
<InviteModalLink id='customNoOptionsMessageLink'>
{chunks}
</InviteModalLink>
),
}}
/>
</div>
);
const content = (
<MultiSelect
key='addUsersToChannelKey'
options={this.state.groupAndUserOptions}
optionRenderer={this.renderOption}
intl={this.props.intl}
selectedItemRef={this.selectedItemRef}
values={this.state.selectedUsers}
ariaLabelRenderer={this.renderAriaLabel}
saveButtonPosition={'bottom'}
perPage={USERS_PER_PAGE}
handlePageChange={this.handlePageChange}
handleInput={this.search}
handleDelete={this.handleDelete}
handleAdd={this.addValue}
handleSubmit={this.handleSubmit}
handleCancel={closeMembersInviteModal}
buttonSubmitText={buttonSubmitText}
buttonSubmitLoadingText={buttonSubmitLoadingText}
saving={this.state.saving}
loading={this.state.loadingUsers}
placeholderText={this.props.isGroupsEnabled ? defineMessage({id: 'multiselect.placeholder.peopleOrGroups', defaultMessage: 'Search for people or groups'}) : defineMessage({id: 'multiselect.placeholder', defaultMessage: 'Search for people'})}
valueWithImage={true}
backButtonText={defineMessage({id: 'multiselect.cancel', defaultMessage: 'Cancel'})}
backButtonClick={closeMembersInviteModal}
backButtonClass={'btn-tertiary tertiary-button'}
customNoOptionsMessage={this.props.emailInvitationsEnabled ? customNoOptionsMessage : null}
/>
);
const inviteGuestLink = (
<InviteModalLink inviteAsGuest={true}>
<FormattedMessage
id='channel_invite.invite_guest'
defaultMessage='Invite as a Guest'
/>
</InviteModalLink>
);
const closeMembersInviteModal = () => {
props.actions.closeModal(ModalIdentifiers.CHANNEL_INVITE);
};
const InviteModalLink = (props: {inviteAsGuest?: boolean; children: React.ReactNode; id?: string}) => {
return (
<Modal
id='addUsersToChannelModal'
dialogClassName='a11y__modal channel-invite'
show={this.state.show}
onHide={this.onHide}
onExited={this.props.onExited}
role='none'
aria-labelledby='channelInviteModalLabel'
<ToggleModalButton
className={`${props.inviteAsGuest ? 'invite-as-guest' : ''} btn btn-link`}
modalId={ModalIdentifiers.INVITATION}
dialogType={InvitationModal}
dialogProps={{
channelToInvite: channel,
initialValue: term,
inviteAsGuest: props.inviteAsGuest,
focusOriginElement: 'customNoOptionsMessageLink',
}}
onClick={closeMembersInviteModal}
id={props.id}
>
<Modal.Header
id='channelInviteModalLabel'
closeButton={true}
>
<Modal.Title
componentClass='h1'
id='deletePostModalLabel'
>
<FormattedMessage
id='channel_invite.addNewMembers'
defaultMessage='Add people to {channel}'
values={{
channel: this.props.channel.display_name,
}}
/>
</Modal.Title>
</Modal.Header>
<Modal.Body
role='application'
className='overflow--visible'
>
{inviteError}
<div className='channel-invite__content'>
{content}
<TeamWarningBanner
guests={this.state.guestsNotInTeam}
teamId={this.props.channel.team_id}
users={this.state.usersNotInTeam}
/>
{(this.props.emailInvitationsEnabled && this.props.canInviteGuests) && inviteGuestLink}
</div>
</Modal.Body>
</Modal>
{props.children}
</ToggleModalButton>
);
};
}
export default injectIntl(ChannelInviteModal);
const customNoOptionsMessage = (
<div
className='custom-no-options-message'
>
<FormattedMessage
id='channel_invite.no_options_message'
defaultMessage='No matches found - <InvitationModalLink>Invite them to the team</InvitationModalLink>'
values={{
InvitationModalLink: (chunks: string) => (
<InviteModalLink id='customNoOptionsMessageLink'>
{chunks}
</InviteModalLink>
),
}}
/>
</div>
);
const content = (
<MultiSelect
key='addUsersToChannelKey'
options={groupAndUserOptions}
optionRenderer={renderOption}
intl={props.intl}
selectedItemRef={selectedItemRef}
values={selectedUsers}
ariaLabelRenderer={renderAriaLabel}
saveButtonPosition={'bottom'}
perPage={USERS_PER_PAGE}
handlePageChange={handlePageChange}
handleInput={search}
handleDelete={handleDelete}
handleAdd={addValue}
handleSubmit={handleSubmit}
handleCancel={closeMembersInviteModal}
buttonSubmitText={buttonSubmitText}
buttonSubmitLoadingText={buttonSubmitLoadingText}
saving={saving}
loading={loadingUsers}
placeholderText={props.isGroupsEnabled ? defineMessage({id: 'multiselect.placeholder.peopleOrGroups', defaultMessage: 'Search for people or groups'}) : defineMessage({id: 'multiselect.placeholder', defaultMessage: 'Search for people'})}
valueWithImage={true}
backButtonText={defineMessage({id: 'multiselect.cancel', defaultMessage: 'Cancel'})}
backButtonClick={closeMembersInviteModal}
backButtonClass={'btn-tertiary tertiary-button'}
customNoOptionsMessage={props.emailInvitationsEnabled ? customNoOptionsMessage : null}
/>
);
const inviteGuestLink = (
<InviteModalLink inviteAsGuest={true}>
<FormattedMessage
id='channel_invite.invite_guest'
defaultMessage='Invite as a Guest'
/>
</InviteModalLink>
);
const {channel} = props;
return (
<GenericModal
id='addUsersToChannelModal'
className='channel-invite'
show={show}
onHide={onHide}
onExited={props.onExited}
modalHeaderText={
<FormattedMessage
id='channel_invite.addNewMembers'
defaultMessage='Add people to {channel}'
values={{
channel: channel.display_name,
}}
/>
}
compassDesign={true}
bodyOverflowVisible={true}
>
<div className='channel-invite__wrapper'>
{inviteError && <label className='has-error control-label'>{inviteError}</label>}
{(channel.policy_enforced) && (
<div className='channel-invite__policy-banner'>
<AlertBanner
mode='info'
variant='app'
title={(
<FormattedMessage
id='channel_invite.policy_enforced.title'
defaultMessage='Channel access is restricted by user attributes'
/>
)}
message={(
<FormattedMessage
id='channel_invite.policy_enforced.description'
defaultMessage='Only people who match the specified access rules can be selected and added to this channel.'
/>
)}
>
{structuredAttributes.length > 0 && (
<TagGroup>
{structuredAttributes.flatMap((attribute) =>
attribute.values.map((value) => (
<AlertTag
key={`${attribute.name}-${value}`}
tooltipTitle={formatAttributeName(attribute.name)}
text={value}
/>
)),
)}
</TagGroup>
)}
</AlertBanner>
</div>
)}
<div className='channel-invite__content'>
{content}
<TeamWarningBanner
guests={guestsNotInTeam}
teamId={channel.team_id}
users={usersNotInTeam}
/>
{(props.emailInvitationsEnabled && props.canInviteGuests) && inviteGuestLink}
</div>
</div>
</GenericModal>
);
};
export default injectIntl(ChannelInviteModalComponent);

Просмотреть файл

@@ -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;
}
}

Просмотреть файл

@@ -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(() => <div data-testid='member-list'>{'Member List Mock'}</div>);
return Object.assign(mockComponent, {
ListItemType,
});
});
jest.mock('./header', () => {
return jest.fn(() => <div data-testid='header'>{'Header Mock'}</div>);
});
jest.mock('./action_bar', () => {
return jest.fn(() => <div data-testid='action-bar'>{'Action Bar Mock'}</div>);
});
jest.mock('./search', () => {
return jest.fn(() => <div data-testid='search-bar'>{'Search Bar Mock'}</div>);
});
// 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(
<ChannelMembersRHS
{...baseProps as any}
/>,
);
// 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(
<ChannelMembersRHS
{...props as any}
/>,
);
expect(screen.getByTestId('search-bar')).toBeInTheDocument();
});
test('should show search bar when search terms are present', () => {
const props = {
...baseProps,
searchTerms: 'test',
};
renderWithContext(
<ChannelMembersRHS
{...props as any}
/>,
);
expect(screen.getByTestId('search-bar')).toBeInTheDocument();
});
test('should not show search bar when there are less than 20 members and no search terms', () => {
renderWithContext(
<ChannelMembersRHS
{...baseProps as any}
/>,
);
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(
<ChannelMembersRHS
{...props as any}
/>,
);
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(
<ChannelMembersRHS
{...props as any}
/>,
);
expect(screen.getByText('Channel access is restricted by user attributes')).toBeInTheDocument();
expect(screen.getByText('tag1')).toBeInTheDocument();
expect(screen.getByText('tag2')).toBeInTheDocument();
});
});

Просмотреть файл

@@ -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: <FirstMemberListSeparator>{text}</FirstMemberListSeparator>,
data: <div className='channel-members-rhs__member-list-separator channel-members-rhs__member-list-separator--first'>{text}</div>,
});
} else if (!memberDone && member.membership?.scheme_admin === false) {
listcp.push({
type: ListItemType.Separator,
data: <MemberListSeparator>
data: <div className='channel-members-rhs__member-list-separator'>
<FormattedMessage
id='channel_members_rhs.list.channel_members_title'
defaultMessage='MEMBERS'
/>
</MemberListSeparator>,
</div>,
});
memberDone = true;
}
@@ -226,7 +224,7 @@ export default function ChannelMembersRHS({
return (
<div
id='rhsContainer'
className='sidebar-right__body'
className='sidebar-right__body channel-members-rhs'
>
<Header
@@ -235,6 +233,34 @@ export default function ChannelMembersRHS({
onClose={actions.closeRightHandSide}
goBack={actions.goBack}
/>
{/* Show banner for policy-enforced channels */}
{channel.policy_enforced && (
<div className='channel-members-rhs__alert-container policy-enforced'>
<AlertBanner
mode='info'
variant='app'
title={formatMessage({
id: 'channel_members_rhs.policy_enforced_restrictions',
defaultMessage: 'Channel access is restricted by user attributes',
})}
>
{structuredAttributes.length > 0 && (
<TagGroup>
{structuredAttributes.flatMap((attribute) =>
attribute.values.map((value) => (
<AlertTag
key={`${attribute.name}-${value}`}
tooltipTitle={formatAttributeName(attribute.name)}
text={value}
/>
)),
)}
</TagGroup>
)}
{loading && <span className='loading-indicator'>{'Loading...'}</span>}
</AlertBanner>
</div>
)}
<ActionBar
channelType={channel.type}
@@ -250,7 +276,7 @@ export default function ChannelMembersRHS({
{/* Users with user management permissions have special restrictions in the default channel */}
{(editing && isDefaultChannel && !currentUserIsChannelAdmin) && (
<AlertContainer>
<div className='channel-members-rhs__alert-container'>
<AlertBanner
mode='info'
variant='app'
@@ -268,7 +294,7 @@ export default function ChannelMembersRHS({
),
})}
/>
</AlertContainer>
</div>
)}
{showSearch && (
@@ -278,7 +304,7 @@ export default function ChannelMembersRHS({
/>
)}
<MembersContainer>
<div className='channel-members-rhs__members-container'>
{channelMembers.length > 0 && (
<MemberList
searchTerms={searchTerms}
@@ -291,26 +317,7 @@ export default function ChannelMembersRHS({
isNextPageLoading={isNextPageLoading}
/>
)}
</MembersContainer>
</div>
</div>
);
}
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;
`;

Просмотреть файл

@@ -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<AnyAction>) {
};
}
export default connect(mapStateToProps, mapDispatchToProps)(RHS);
export default connect(mapStateToProps, mapDispatchToProps)(ChannelMembersRHS);

Просмотреть файл

@@ -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 (
<div
className={className}
className='channel-members-rhs__member'
style={{height: '48px'}}
data-testid={`memberline-${member.user.id}`}
>
<span className='ProfileSpan'>
<Avatar>
<div className='channel-members-rhs__avatar'>
<ProfilePicture
size='sm'
status={member.status}
@@ -125,27 +53,27 @@ const Member = ({className, channel, member, index, totalUsers, editing, actions
username={member.displayName}
src={userProfileSrc}
/>
</Avatar>
</div>
<ProfilePopover
triggerComponentClass='profileSpan_userInfo'
userId={member.user.id}
src={userProfileSrc}
hideStatus={member.user.is_bot}
>
<DisplayName>
<span className='channel-members-rhs__display-name'>
{member.displayName}
{isGuest(member.user.roles) && <GuestTag/>}
{member.user.remote_id &&
(
<SharedIcon>
<span className='channel-members-rhs__shared-icon'>
<SharedChannelIndicator
withTooltip={true}
/>
</SharedIcon>
</span>
)}
</DisplayName>
</span>
{
member.displayName === member.user.username ? null : <Username>{'@'}{member.user.username}</Username>
member.displayName === member.user.username ? null : <span className='channel-members-rhs__username'>{'@'}{member.user.username}</span>
}
<CustomStatusEmoji
userID={member.user.id}
@@ -164,8 +92,8 @@ const Member = ({className, channel, member, index, totalUsers, editing, actions
</ProfilePopover>
</span>
<RoleChooser
className={classNames({editing}, 'member-role-chooser')}
<div
className={classNames('channel-members-rhs__role-chooser', {editing})}
data-testid='rolechooser'
>
{member.membership && (
@@ -195,7 +123,7 @@ const Member = ({className, channel, member, index, totalUsers, editing, actions
}
/>
)}
</RoleChooser>
</div>
{!editing && (
<WithTooltip
title={formatMessage({
@@ -203,51 +131,16 @@ const Member = ({className, channel, member, index, totalUsers, editing, actions
defaultMessage: 'Send message',
})}
>
<SendMessage onClick={() => actions.openDirectMessage(member.user)}>
<button
className='channel-members-rhs__send-message'
onClick={() => actions.openDirectMessage(member.user)}
>
<i className='icon icon-send'/>
</SendMessage>
</button>
</WithTooltip>
)}
</div>
);
};
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;

Просмотреть файл

@@ -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;
}

Просмотреть файл

@@ -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}) => (
<Provider store={store}>
{children}
</Provider>
);
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');
});
});

Просмотреть файл

@@ -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<string, {
processedData: {
attributeTags: string[];
structuredAttributes: AccessControlAttribute[];
};
timestamp: number;
}> = {};
// 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<string[]>([]);
const [structuredAttributes, setStructuredAttributes] = useState<AccessControlAttribute[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const dispatch = useDispatch();
// Helper function to process attribute data and extract tags
const processAttributeData = useCallback((data: Record<string, string[]> | undefined) => {
if (!data) {
setAttributeTags([]);
setStructuredAttributes([]);
return;
}
const tags: string[] = [];
const attributes: AccessControlAttribute[] = [];
// Extract values from all properties in the response
// Format: { "attributeName": ["value1", "value2"], "anotherAttribute": ["value3"] }
Object.entries(data).forEach(([name, values]) => {
// Add to structured format
if (Array.isArray(values)) {
attributes.push({name, values: [...values]});
// Add to flat tags (existing behavior)
values.forEach((value) => {
if (value !== undefined && value !== null) {
tags.push(value);
}
});
}
});
setAttributeTags(tags);
setStructuredAttributes(attributes);
}, []);
const 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;

Просмотреть файл

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/widgets/tag/AlertTag should match snapshot 1`] = `
<div>
<span
class="AlertTag AlertTag--default AlertTag--medium"
>
Tag Text
</span>
</div>
`;

Просмотреть файл

@@ -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;
}
}

Просмотреть файл

@@ -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(
<AlertTag
text='Tag Text'
/>,
);
expect(container).toMatchSnapshot();
});
test('should render the text correctly', () => {
render(
<AlertTag
text='Tag Text'
/>,
);
expect(screen.getByText('Tag Text')).toBeInTheDocument();
});
test('should apply custom className', () => {
const {container} = render(
<AlertTag
text='Tag Text'
className='custom-class'
/>,
);
expect(container.querySelector('.custom-class')).not.toBeNull();
});
test('should apply variant class', () => {
const {container} = render(
<AlertTag
text='Tag Text'
variant='primary'
/>,
);
expect(container.querySelector('.AlertTag--primary')).not.toBeNull();
});
test('should apply size class', () => {
const {container} = render(
<AlertTag
text='Tag Text'
size='small'
/>,
);
expect(container.querySelector('.AlertTag--small')).not.toBeNull();
});
test('should apply clickable class when onClick is provided', () => {
const onClick = jest.fn();
const {container} = render(
<AlertTag
text='Tag Text'
onClick={onClick}
/>,
);
expect(container.querySelector('.AlertTag--clickable')).not.toBeNull();
});
test('should call onClick when clicked', async () => {
const onClick = jest.fn();
render(
<AlertTag
text='Tag Text'
onClick={onClick}
/>,
);
await userEvent.click(screen.getByText('Tag Text'));
expect(onClick).toHaveBeenCalledTimes(1);
});
test('should apply testId', () => {
render(
<AlertTag
text='Tag Text'
testId='test-id'
/>,
);
expect(screen.getByTestId('test-id')).toBeInTheDocument();
});
test('should use span element when onClick is not provided', () => {
render(
<AlertTag
text='Tag Text'
/>,
);
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(
<AlertTag
text='Tag Text'
onClick={onClick}
/>,
);
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(
<AlertTag
text='Tag Text'
onClick={onClick}
/>,
);
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(
<AlertTag
text='Tag Text'
tooltipTitle='Tooltip Text'
/>,
);
// 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);
});
});

Просмотреть файл

@@ -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<AlertTagProps> = ({
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 = (
<TagElement
className={classNames(
'AlertTag',
`AlertTag--${variant}`,
`AlertTag--${size}`,
{
'AlertTag--clickable': Boolean(onClick),
},
className,
)}
onClick={onClick}
data-testid={testId}
// Add type="button" to prevent form submission if used within a form
{...(onClick && {type: 'button'})}
>
{text}
</TagElement>
);
// If tooltipTitle is provided, wrap the tag with WithTooltip
if (tooltipTitle) {
return (
<WithTooltip
title={tooltipTitle}
>
{tagElement}
</WithTooltip>
);
}
// Otherwise, just return the tag
return tagElement;
};
export default AlertTag;

Просмотреть файл

@@ -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;
}

Просмотреть файл

@@ -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<TagGroupProps> = ({
children,
className,
}) => {
return (
<div className={classNames('TagGroup', className)}>
{children}
</div>
);
};
export default TagGroup;

Просмотреть файл

@@ -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 - <InvitationModalLink>Invite them to the team</InvitationModalLink>",
"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",

Просмотреть файл

@@ -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<Arra
};
}
export function getChannelAccessControlAttributes(channelId: string): ActionFuncAsync<AccessControlAttributes> {
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,
};

Просмотреть файл

@@ -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<AccessControlAttributes>(
`${this.getChannelRoute(channelId)}/access_control/attributes`,
{method: 'get'},
);
};
}
export function parseAndMergeNestedHeaders(originalHeaders: any) {

Просмотреть файл

@@ -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<string, string[]>;
/**
* Interface for entities that can have access control
*/
export interface AccessControlled {
/**
* Whether access control is enforced for this entity
*/
access_control_enforced?: boolean;
}