Mm 63844 add end user indicators (#30971)
MM-63844 - ABAC add end user indicators to channel manage users
Этот коммит содержится в:
@@ -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>
|
||||
`;
|
||||
71
webapp/channels/src/components/widgets/tag/alert_tag.scss
Обычный файл
71
webapp/channels/src/components/widgets/tag/alert_tag.scss
Обычный файл
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.AlertTag {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
// Reset button styles when using button element
|
||||
&:is(button) {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
outline: inherit;
|
||||
}
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// Variants
|
||||
&--default {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
color: var(--center-channel-color);
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background-color: rgba(var(--button-bg-rgb), 0.08);
|
||||
color: var(--button-bg);
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background-color: rgba(var(--button-color-rgb), 0.08);
|
||||
color: var(--button-color);
|
||||
}
|
||||
|
||||
&--info {
|
||||
background-color: rgba(var(--info-color-rgb), 0.08);
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
// Sizes
|
||||
&--small {
|
||||
height: 16px;
|
||||
padding: 0 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
height: 20px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--large {
|
||||
height: 24px;
|
||||
padding: 4px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
156
webapp/channels/src/components/widgets/tag/alert_tag.test.tsx
Обычный файл
156
webapp/channels/src/components/widgets/tag/alert_tag.test.tsx
Обычный файл
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen, render} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import AlertTag from './alert_tag';
|
||||
|
||||
describe('components/widgets/tag/AlertTag', () => {
|
||||
test('should match snapshot', () => {
|
||||
const {container} = render(
|
||||
<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);
|
||||
});
|
||||
});
|
||||
81
webapp/channels/src/components/widgets/tag/alert_tag.tsx
Обычный файл
81
webapp/channels/src/components/widgets/tag/alert_tag.tsx
Обычный файл
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
import './alert_tag.scss';
|
||||
|
||||
export type AlertTagProps = {
|
||||
|
||||
text: string;
|
||||
|
||||
className?: string;
|
||||
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
|
||||
variant?: 'default' | 'primary' | 'secondary' | 'info';
|
||||
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
|
||||
testId?: string;
|
||||
|
||||
tooltipTitle?: string | ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A tag component used for displaying alert information
|
||||
* Optionally includes tooltip functionality when tooltipTitle is provided
|
||||
*/
|
||||
const AlertTag: React.FC<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;
|
||||
8
webapp/channels/src/components/widgets/tag/tag_group.scss
Обычный файл
8
webapp/channels/src/components/widgets/tag/tag_group.scss
Обычный файл
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.TagGroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
36
webapp/channels/src/components/widgets/tag/tag_group.tsx
Обычный файл
36
webapp/channels/src/components/widgets/tag/tag_group.tsx
Обычный файл
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import './tag_group.scss';
|
||||
|
||||
export type TagGroupProps = {
|
||||
|
||||
/**
|
||||
* Child elements (typically Tag components)
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Optional CSS class name for custom styling
|
||||
*/
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component for grouping and displaying multiple tags
|
||||
*/
|
||||
const TagGroup: React.FC<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;
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user