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
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`components/channel_invite_modal should match snapshot for channel_invite_modal with profiles 1`] = `
|
exports[`components/channel_invite_modal should match snapshot for channel_invite_modal with profiles 1`] = `
|
||||||
<Modal
|
<GenericModal
|
||||||
animation={true}
|
bodyOverflowVisible={true}
|
||||||
aria-labelledby="channelInviteModalLabel"
|
className="channel-invite"
|
||||||
autoFocus={true}
|
compassDesign={true}
|
||||||
backdrop={true}
|
|
||||||
bsClass="modal"
|
|
||||||
dialogClassName="a11y__modal channel-invite"
|
|
||||||
dialogComponentClass={[Function]}
|
|
||||||
enforceFocus={true}
|
|
||||||
id="addUsersToChannelModal"
|
id="addUsersToChannelModal"
|
||||||
keyboard={true}
|
modalHeaderText={
|
||||||
manager={
|
<Memo(MemoizedFormattedMessage)
|
||||||
ModalManager {
|
defaultMessage="Add people to {channel}"
|
||||||
"add": [Function],
|
id="channel_invite.addNewMembers"
|
||||||
"containers": Array [],
|
values={
|
||||||
"data": Array [],
|
Object {
|
||||||
"handleContainerOverflow": true,
|
"channel": "testing",
|
||||||
"hideSiblingNodes": true,
|
}
|
||||||
"isTopModal": [Function],
|
}
|
||||||
"modals": Array [],
|
/>
|
||||||
"remove": [Function],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
onExited={[MockFunction]}
|
onExited={[MockFunction]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
renderBackdrop={[Function]}
|
|
||||||
restoreFocus={true}
|
|
||||||
role="none"
|
|
||||||
show={true}
|
show={true}
|
||||||
>
|
>
|
||||||
<ModalHeader
|
<div
|
||||||
bsClass="modal-header"
|
className="channel-invite__wrapper"
|
||||||
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
|
<div
|
||||||
className="channel-invite__content"
|
className="channel-invite__content"
|
||||||
@@ -158,68 +123,33 @@ exports[`components/channel_invite_modal should match snapshot for channel_invit
|
|||||||
users={Array []}
|
users={Array []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</div>
|
||||||
</Modal>
|
</GenericModal>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`components/channel_invite_modal should match snapshot for channel_invite_modal with profiles from DMs 1`] = `
|
exports[`components/channel_invite_modal should match snapshot for channel_invite_modal with profiles from DMs 1`] = `
|
||||||
<Modal
|
<GenericModal
|
||||||
animation={true}
|
bodyOverflowVisible={true}
|
||||||
aria-labelledby="channelInviteModalLabel"
|
className="channel-invite"
|
||||||
autoFocus={true}
|
compassDesign={true}
|
||||||
backdrop={true}
|
|
||||||
bsClass="modal"
|
|
||||||
dialogClassName="a11y__modal channel-invite"
|
|
||||||
dialogComponentClass={[Function]}
|
|
||||||
enforceFocus={true}
|
|
||||||
id="addUsersToChannelModal"
|
id="addUsersToChannelModal"
|
||||||
keyboard={true}
|
modalHeaderText={
|
||||||
manager={
|
<Memo(MemoizedFormattedMessage)
|
||||||
ModalManager {
|
defaultMessage="Add people to {channel}"
|
||||||
"add": [Function],
|
id="channel_invite.addNewMembers"
|
||||||
"containers": Array [],
|
values={
|
||||||
"data": Array [],
|
Object {
|
||||||
"handleContainerOverflow": true,
|
"channel": "testing",
|
||||||
"hideSiblingNodes": true,
|
}
|
||||||
"isTopModal": [Function],
|
}
|
||||||
"modals": Array [],
|
/>
|
||||||
"remove": [Function],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
onExited={[MockFunction]}
|
onExited={[MockFunction]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
renderBackdrop={[Function]}
|
|
||||||
restoreFocus={true}
|
|
||||||
role="none"
|
|
||||||
show={true}
|
show={true}
|
||||||
>
|
>
|
||||||
<ModalHeader
|
<div
|
||||||
bsClass="modal-header"
|
className="channel-invite__wrapper"
|
||||||
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
|
<div
|
||||||
className="channel-invite__content"
|
className="channel-invite__content"
|
||||||
@@ -320,90 +250,33 @@ exports[`components/channel_invite_modal should match snapshot for channel_invit
|
|||||||
users={Array []}
|
users={Array []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</div>
|
||||||
</Modal>
|
</GenericModal>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`components/channel_invite_modal should match snapshot for channel_invite_modal with userStatuses 1`] = `
|
exports[`components/channel_invite_modal should match snapshot for channel_invite_modal with userStatuses 1`] = `
|
||||||
<Memo(GroupOption)
|
<GenericModal
|
||||||
addUserProfile={[MockFunction]}
|
bodyOverflowVisible={true}
|
||||||
group={
|
className="channel-invite"
|
||||||
Object {
|
compassDesign={true}
|
||||||
"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}
|
|
||||||
id="addUsersToChannelModal"
|
id="addUsersToChannelModal"
|
||||||
keyboard={true}
|
modalHeaderText={
|
||||||
manager={
|
<Memo(MemoizedFormattedMessage)
|
||||||
ModalManager {
|
defaultMessage="Add people to {channel}"
|
||||||
"add": [Function],
|
id="channel_invite.addNewMembers"
|
||||||
"containers": Array [],
|
values={
|
||||||
"data": Array [],
|
Object {
|
||||||
"handleContainerOverflow": true,
|
"channel": "testing",
|
||||||
"hideSiblingNodes": true,
|
}
|
||||||
"isTopModal": [Function],
|
}
|
||||||
"modals": Array [],
|
/>
|
||||||
"remove": [Function],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
onExited={[MockFunction]}
|
onExited={[MockFunction]}
|
||||||
onHide={[Function]}
|
onHide={[Function]}
|
||||||
renderBackdrop={[Function]}
|
|
||||||
restoreFocus={true}
|
|
||||||
role="none"
|
|
||||||
show={true}
|
show={true}
|
||||||
>
|
>
|
||||||
<ModalHeader
|
<div
|
||||||
bsClass="modal-header"
|
className="channel-invite__wrapper"
|
||||||
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
|
<div
|
||||||
className="channel-invite__content"
|
className="channel-invite__content"
|
||||||
@@ -504,6 +377,133 @@ exports[`components/channel_invite_modal should match snapshot with exclude and
|
|||||||
users={Array []}
|
users={Array []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</div>
|
||||||
</Modal>
|
</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.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// 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 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 {Channel} from '@mattermost/types/channels';
|
||||||
|
import type {TeamMembership} from '@mattermost/types/teams';
|
||||||
import type {UserProfile} from '@mattermost/types/users';
|
import type {UserProfile} from '@mattermost/types/users';
|
||||||
import type {RelationOneToOne} from '@mattermost/types/utilities';
|
import type {RelationOneToOne} from '@mattermost/types/utilities';
|
||||||
|
|
||||||
import {General} from 'mattermost-redux/constants';
|
import {General} from 'mattermost-redux/constants';
|
||||||
|
|
||||||
import ChannelInviteModal from 'components/channel_invite_modal/channel_invite_modal';
|
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 type {Value} from 'components/multiselect/multiselect';
|
||||||
|
|
||||||
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
|
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
|
||||||
|
import {renderWithContext} from 'tests/react_testing_utils';
|
||||||
|
|
||||||
type UserProfileValue = Value & UserProfile;
|
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', () => {
|
jest.mock('utils/utils', () => {
|
||||||
const original = jest.requireActual('utils/utils');
|
const original = jest.requireActual('utils/utils');
|
||||||
return {
|
return {
|
||||||
@@ -31,11 +62,13 @@ describe('components/channel_invite_modal', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
label: 'user-1',
|
label: 'user-1',
|
||||||
value: 'user-1',
|
value: 'user-1',
|
||||||
|
username: 'user-1',
|
||||||
delete_at: 0,
|
delete_at: 0,
|
||||||
} as UserProfileValue, {
|
} as UserProfileValue, {
|
||||||
id: 'user-2',
|
id: 'user-2',
|
||||||
label: 'user-2',
|
label: 'user-2',
|
||||||
value: 'user-2',
|
value: 'user-2',
|
||||||
|
username: 'user-2',
|
||||||
delete_at: 0,
|
delete_at: 0,
|
||||||
} as UserProfileValue];
|
} as UserProfileValue];
|
||||||
|
|
||||||
@@ -160,118 +193,236 @@ describe('components/channel_invite_modal', () => {
|
|||||||
profilesFromRecentDMs={[]}
|
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(
|
const wrapper = shallowWithIntl(
|
||||||
<ChannelInviteModal {...baseProps}/>,
|
<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;
|
// Re-render to reflect state changes
|
||||||
instance.onHide();
|
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 props = {...baseProps};
|
||||||
const wrapper = shallowWithIntl(
|
const wrapper = shallowWithIntl(
|
||||||
<ChannelInviteModal {...props}/>,
|
<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);
|
expect(props.onExited).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail to add users on handleSubmit', (done) => {
|
test('should fail to add users on handleSubmit', async () => {
|
||||||
const wrapper = shallowWithIntl(
|
// Mock the addUsersToChannel function to return an error
|
||||||
<ChannelInviteModal
|
const addUsersToChannelMock = jest.fn().mockImplementation(() => {
|
||||||
{...baseProps}
|
return Promise.resolve({error: {message: 'Failed'}});
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
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 add users on handleSubmit', (done) => {
|
|
||||||
const props = {
|
const props = {
|
||||||
...baseProps,
|
...baseProps,
|
||||||
actions: {
|
actions: {
|
||||||
...baseProps.actions,
|
...baseProps.actions,
|
||||||
addUsersToChannel: jest.fn().mockImplementation(() => {
|
addUsersToChannel: addUsersToChannelMock,
|
||||||
const data = true;
|
|
||||||
return Promise.resolve({data});
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
|
profilesNotInCurrentChannel: [users[0]],
|
||||||
|
includeUsers: {'user-1': users[0]},
|
||||||
|
membersInTeam: {'user-1': {user_id: 'user-1', team_id: channel.team_id, roles: '', delete_at: 0, scheme_admin: false, scheme_guest: false, scheme_user: true, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0} as TeamMembership},
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapper = shallowWithIntl(
|
await act(async () => {
|
||||||
<ChannelInviteModal
|
const {getByText} = renderWithContext(
|
||||||
{...props}
|
<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;
|
// Type the search term
|
||||||
instance.handleSubmit();
|
await userEvent.type(input, 'user-1');
|
||||||
|
|
||||||
expect(wrapper.state('saving')).toEqual(true);
|
// Wait for the promise to resolve
|
||||||
expect(instance.props.actions.addUsersToChannel).toHaveBeenCalledTimes(1);
|
await act(async () => {
|
||||||
process.nextTick(() => {
|
// Wait for the dropdown option to appear
|
||||||
expect(wrapper.state('inviteError')).toBeUndefined();
|
const option = await screen.findByText('user-1');
|
||||||
expect(wrapper.state('saving')).toEqual(false);
|
|
||||||
expect(wrapper.state('show')).toEqual(false);
|
// Click the option
|
||||||
done();
|
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 onAddCallback = jest.fn();
|
||||||
|
|
||||||
const props = {
|
const props = {
|
||||||
...baseProps,
|
...baseProps,
|
||||||
skipCommit: true,
|
skipCommit: true,
|
||||||
onAddCallback,
|
onAddCallback,
|
||||||
|
profilesNotInCurrentChannel: [users[0]],
|
||||||
|
includeUsers: {'user-1': users[0]},
|
||||||
|
membersInTeam: {'user-1': {user_id: 'user-1', team_id: channel.team_id, roles: '', delete_at: 0, scheme_admin: false, scheme_guest: false, scheme_user: true, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0} as TeamMembership},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapper = shallowWithIntl(
|
await act(async () => {
|
||||||
<ChannelInviteModal
|
const {getByText} = renderWithContext(
|
||||||
{...props}
|
<ChannelInviteModal
|
||||||
/>,
|
{...props}
|
||||||
);
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
wrapper.setState({selectedUsers: users, show: true});
|
// First, we need to simulate selecting a user
|
||||||
const instance = wrapper.instance() as ChannelInviteModalClass;
|
const input = screen.getByRole('combobox', {name: /search for people/i});
|
||||||
instance.handleSubmit();
|
|
||||||
|
|
||||||
expect(onAddCallback).toHaveBeenCalled();
|
await userEvent.type(input, 'user-1');
|
||||||
expect(instance.props.actions.addUsersToChannel).toHaveBeenCalledTimes(0);
|
|
||||||
|
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', () => {
|
test('should trim the search term', async () => {
|
||||||
const wrapper = shallowWithIntl(
|
const searchProfilesMock = jest.fn().mockImplementation(() => {
|
||||||
<ChannelInviteModal {...baseProps}/>,
|
return Promise.resolve({});
|
||||||
);
|
});
|
||||||
|
|
||||||
const instance = wrapper.instance() as ChannelInviteModalClass;
|
const props = {
|
||||||
|
...baseProps,
|
||||||
|
actions: {
|
||||||
|
...baseProps.actions,
|
||||||
|
searchProfiles: searchProfilesMock,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
instance.search(' something ');
|
await act(async () => {
|
||||||
expect(wrapper.state('term')).toEqual('something');
|
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', () => {
|
test('should send the invite as guest param through the link', () => {
|
||||||
@@ -305,4 +456,137 @@ describe('components/channel_invite_modal', () => {
|
|||||||
|
|
||||||
expect(invitationLink).toHaveLength(0);
|
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.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import './channel_invite_modal.scss';
|
||||||
|
|
||||||
import isEqual from 'lodash/isEqual';
|
import isEqual from 'lodash/isEqual';
|
||||||
import React from 'react';
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import {Modal} from 'react-bootstrap';
|
|
||||||
import type {IntlShape} from 'react-intl';
|
import type {IntlShape} from 'react-intl';
|
||||||
import {injectIntl, FormattedMessage, defineMessage} 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 {Channel} from '@mattermost/types/channels';
|
||||||
import type {Group, GroupSearchParams} from '@mattermost/types/groups';
|
import type {Group, GroupSearchParams} from '@mattermost/types/groups';
|
||||||
import type {TeamMembership} from '@mattermost/types/teams';
|
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 {filterGroupsMatchingTerm} from 'mattermost-redux/utils/group_utils';
|
||||||
import {displayUsername, filterProfilesStartingWithTerm, isGuest} from 'mattermost-redux/utils/user_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 InvitationModal from 'components/invitation_modal';
|
||||||
import MultiSelect from 'components/multiselect/multiselect';
|
import MultiSelect from 'components/multiselect/multiselect';
|
||||||
import type {Value} from 'components/multiselect/multiselect';
|
import type {Value} from 'components/multiselect/multiselect';
|
||||||
import ProfilePicture from 'components/profile_picture';
|
import ProfilePicture from 'components/profile_picture';
|
||||||
import ToggleModalButton from 'components/toggle_modal_button';
|
import ToggleModalButton from 'components/toggle_modal_button';
|
||||||
|
import AlertTag from 'components/widgets/tag/alert_tag';
|
||||||
import BotTag from 'components/widgets/tag/bot_tag';
|
import BotTag from 'components/widgets/tag/bot_tag';
|
||||||
import GuestTag from 'components/widgets/tag/guest_tag';
|
import GuestTag from 'components/widgets/tag/guest_tag';
|
||||||
|
import TagGroup from 'components/widgets/tag/tag_group';
|
||||||
|
|
||||||
import Constants, {ModalIdentifiers} from 'utils/constants';
|
import Constants, {ModalIdentifiers} from 'utils/constants';
|
||||||
import {sortUsersAndGroups} from 'utils/utils';
|
import {sortUsersAndGroups} from 'utils/utils';
|
||||||
@@ -79,161 +84,106 @@ export type Props = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
// Helper function to check if an option is a user
|
||||||
selectedUsers: UserProfileValue[];
|
const isUser = (option: UserProfileValue | GroupValue): option is UserProfileValue => {
|
||||||
groupAndUserOptions: Array<UserProfileValue | GroupValue>;
|
return (option as UserProfile).username !== undefined;
|
||||||
usersNotInTeam: UserProfileValue[];
|
};
|
||||||
guestsNotInTeam: UserProfileValue[];
|
|
||||||
term: string;
|
|
||||||
show: boolean;
|
|
||||||
saving: boolean;
|
|
||||||
loadingUsers: boolean;
|
|
||||||
inviteError?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UsernameSpan = styled.span`
|
const ChannelInviteModalComponent = (props: Props) => {
|
||||||
fontSize: 12px;
|
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`
|
const searchTimeoutId = useRef<number>(0);
|
||||||
position: absolute;
|
const selectedItemRef = useRef<HTMLDivElement>(null);
|
||||||
right: 20px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export class ChannelInviteModal extends React.PureComponent<Props, State> {
|
// Use the useAccessControlAttributes hook
|
||||||
private searchTimeoutId = 0;
|
const {structuredAttributes} = useAccessControlAttributes(
|
||||||
private selectedItemRef = React.createRef<HTMLDivElement>();
|
EntityType.Channel,
|
||||||
|
props.channel.id,
|
||||||
|
props.channel.policy_enforced,
|
||||||
|
);
|
||||||
|
|
||||||
public static defaultProps = {
|
// Helper function to format attribute names for tooltips
|
||||||
includeUsers: {},
|
const formatAttributeName = (name: string): string => {
|
||||||
excludeUsers: {},
|
// Convert snake_case or camelCase to Title Case with spaces
|
||||||
skipCommit: false,
|
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) {
|
// Helper function to add a user or group to the selected list
|
||||||
super(props);
|
const addValue = useCallback((value: UserProfileValue | GroupValue) => {
|
||||||
this.state = {
|
if (isUser(value)) {
|
||||||
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)) {
|
|
||||||
const profile = 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 (isGuest(profile.roles)) {
|
||||||
if (this.state.guestsNotInTeam.indexOf(profile) === -1) {
|
setGuestsNotInTeam((prevState) => {
|
||||||
this.setState((prevState) => {
|
if (prevState.findIndex((p) => p.id === profile.id) === -1) {
|
||||||
return {guestsNotInTeam: [...prevState.guestsNotInTeam, profile]};
|
return [...prevState, profile];
|
||||||
});
|
}
|
||||||
}
|
return prevState;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.state.usersNotInTeam.indexOf(profile) === -1) {
|
setUsersNotInTeam((prevState) => {
|
||||||
this.setState((prevState) => {
|
if (prevState.findIndex((p) => p.id === profile.id) === -1) {
|
||||||
return {usersNotInTeam: [...prevState.usersNotInTeam, profile]};
|
return [...prevState, profile];
|
||||||
});
|
}
|
||||||
}
|
return prevState;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.selectedUsers.indexOf(profile) === -1) {
|
setSelectedUsers((prevState) => {
|
||||||
this.setState((prevState) => {
|
if (prevState.findIndex((p) => p.id === profile.id) === -1) {
|
||||||
return {selectedUsers: [...prevState.selectedUsers, profile]};
|
return [...prevState, 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);
|
|
||||||
}
|
}
|
||||||
}
|
return prevState;
|
||||||
|
});
|
||||||
if (!isEqual(values, this.state.groupAndUserOptions)) {
|
|
||||||
if (userIds.length > 0) {
|
|
||||||
this.props.actions.getTeamMembersByIds(this.props.channel.team_id, userIds);
|
|
||||||
}
|
|
||||||
this.setState({groupAndUserOptions: values});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}, [props.membersInTeam, isGuest]);
|
||||||
|
|
||||||
getExcludedUsers = (): Set<string> => {
|
// Get excluded users
|
||||||
if (this.props.excludeUsers) {
|
const excludedUsers = useMemo(() => {
|
||||||
return new Set(...this.props.profilesNotInCurrentTeam.map((user) => user.id), Object.values(this.props.excludeUsers).map((user) => user.id));
|
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
|
// Filter out deleted and excluded users
|
||||||
public getOptions = () => {
|
const filterOutDeletedAndExcludedAndNotInTeamUsers = useCallback((users: UserProfile[], excludeUserIds: Set<string>): UserProfileValue[] => {
|
||||||
const excludedAndNotInTeamUserIds = this.getExcludedUsers();
|
return users.filter((user) => {
|
||||||
|
return user.delete_at === 0 && !excludeUserIds.has(user.id);
|
||||||
|
}) as UserProfileValue[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filteredDmUsers = filterProfilesStartingWithTerm(this.props.profilesFromRecentDMs, this.state.term);
|
// Get options for the multiselect
|
||||||
const dmUsers = this.filterOutDeletedAndExcludedAndNotInTeamUsers(filteredDmUsers, excludedAndNotInTeamUserIds).slice(0, USERS_FROM_DMS) as UserProfileValue[];
|
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[];
|
let users: UserProfileValue[];
|
||||||
const filteredUsers: UserProfile[] = filterProfilesStartingWithTerm(this.props.profilesNotInCurrentChannel.concat(this.props.profilesInCurrentChannel), this.state.term);
|
const filteredUsers: UserProfile[] = filterProfilesStartingWithTerm(props.profilesNotInCurrentChannel.concat(props.profilesInCurrentChannel), term);
|
||||||
users = this.filterOutDeletedAndExcludedAndNotInTeamUsers(filteredUsers, excludedAndNotInTeamUserIds);
|
users = filterOutDeletedAndExcludedAndNotInTeamUsers(filteredUsers, excludedAndNotInTeamUserIds);
|
||||||
if (this.props.includeUsers) {
|
if (props.includeUsers) {
|
||||||
users = [...users, ...Object.values(this.props.includeUsers)];
|
users = [...users, ...Object.values(props.includeUsers)];
|
||||||
}
|
}
|
||||||
const groupsAndUsers = [
|
const groupsAndUsers = [
|
||||||
...filterGroupsMatchingTerm(this.props.groups, this.state.term) as GroupValue[],
|
...filterGroupsMatchingTerm(props.groups, term) as GroupValue[],
|
||||||
...users,
|
...users,
|
||||||
].sort(sortUsersAndGroups);
|
].sort(sortUsersAndGroups);
|
||||||
|
|
||||||
@@ -243,98 +193,106 @@ export class ChannelInviteModal extends React.PureComponent<Props, State> {
|
|||||||
].slice(0, MAX_USERS);
|
].slice(0, MAX_USERS);
|
||||||
|
|
||||||
return Array.from(new Set(optionValues));
|
return Array.from(new Set(optionValues));
|
||||||
};
|
}, [
|
||||||
|
term,
|
||||||
|
props.profilesFromRecentDMs,
|
||||||
|
props.profilesNotInCurrentChannel,
|
||||||
|
props.profilesInCurrentChannel,
|
||||||
|
props.includeUsers,
|
||||||
|
props.groups,
|
||||||
|
excludedUsers,
|
||||||
|
filterOutDeletedAndExcludedAndNotInTeamUsers,
|
||||||
|
]);
|
||||||
|
|
||||||
public onHide = (): void => {
|
// Handle modal hide
|
||||||
this.setState({show: false});
|
const onHide = useCallback(() => {
|
||||||
this.props.actions.loadStatusesForProfilesList(this.props.profilesNotInCurrentChannel);
|
setShow(false);
|
||||||
this.props.actions.loadStatusesForProfilesList(this.props.profilesInCurrentChannel);
|
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) {
|
if (err) {
|
||||||
this.setState({
|
setSaving(false);
|
||||||
saving: false,
|
setInviteError(err.message);
|
||||||
inviteError: 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
|
// Our values for this component are always UserProfileValue
|
||||||
const profiles = values as UserProfileValue[];
|
const profiles = values as UserProfileValue[];
|
||||||
this.setState({selectedUsers: profiles});
|
setSelectedUsers(profiles);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
private setUsersLoadingState = (loadingState: boolean): void => {
|
// Set users loading state
|
||||||
this.setState({
|
const setUsersLoadingState = useCallback((loadingState: boolean) => {
|
||||||
loadingUsers: loadingState,
|
setLoadingUsers(loadingState);
|
||||||
});
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
private handlePageChange = (page: number, prevPage: number): void => {
|
// Handle page change
|
||||||
|
const handlePageChange = useCallback((page: number, prevPage: number) => {
|
||||||
if (page > prevPage) {
|
if (page > prevPage) {
|
||||||
this.setUsersLoadingState(true);
|
setUsersLoadingState(true);
|
||||||
this.props.actions.getProfilesNotInChannel(
|
props.actions.getProfilesNotInChannel(
|
||||||
this.props.channel.team_id,
|
props.channel.team_id,
|
||||||
this.props.channel.id,
|
props.channel.id,
|
||||||
this.props.channel.group_constrained,
|
props.channel.group_constrained,
|
||||||
page + 1, USERS_PER_PAGE).then(() => this.setUsersLoadingState(false));
|
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 => {
|
// Handle form submission
|
||||||
const {actions, channel} = this.props;
|
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) {
|
if (userIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.skipCommit && this.props.onAddCallback) {
|
if (props.skipCommit && props.onAddCallback) {
|
||||||
this.props.onAddCallback(this.state.selectedUsers);
|
props.onAddCallback(selectedUsers);
|
||||||
this.setState({
|
setSaving(false);
|
||||||
saving: false,
|
setInviteError(undefined);
|
||||||
inviteError: undefined,
|
onHide();
|
||||||
});
|
|
||||||
this.onHide();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({saving: true});
|
setSaving(true);
|
||||||
|
|
||||||
actions.addUsersToChannel(channel.id, userIds).then((result) => {
|
actions.addUsersToChannel(channel.id, userIds).then((result) => {
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
this.handleInviteError(result.error);
|
handleInviteError(result.error);
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
setSaving(false);
|
||||||
saving: false,
|
setInviteError(undefined);
|
||||||
inviteError: undefined,
|
onHide();
|
||||||
});
|
|
||||||
this.onHide();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
}, [props, selectedUsers, handleInviteError, onHide]);
|
||||||
|
|
||||||
public search = (searchTerm: string): void => {
|
// Handle search
|
||||||
|
const search = useCallback((searchTerm: string) => {
|
||||||
const term = searchTerm.trim();
|
const term = searchTerm.trim();
|
||||||
clearTimeout(this.searchTimeoutId);
|
clearTimeout(searchTimeoutId.current);
|
||||||
this.setState({
|
setTerm(term);
|
||||||
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 () => {
|
async () => {
|
||||||
if (!term) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
team_id: this.props.channel.team_id,
|
team_id: props.channel.team_id,
|
||||||
not_in_channel_id: this.props.channel.id,
|
not_in_channel_id: props.channel.id,
|
||||||
group_constrained: this.props.channel.group_constrained,
|
group_constrained: Boolean(props.channel.group_constrained),
|
||||||
};
|
};
|
||||||
|
|
||||||
const opts = {
|
const opts = {
|
||||||
@@ -346,59 +304,55 @@ export class ChannelInviteModal extends React.PureComponent<Props, State> {
|
|||||||
include_member_ids: true,
|
include_member_ids: true,
|
||||||
};
|
};
|
||||||
const promises = [
|
const promises = [
|
||||||
this.props.actions.searchProfiles(term, options),
|
props.actions.searchProfiles(term, options),
|
||||||
];
|
];
|
||||||
if (this.props.isGroupsEnabled) {
|
if (props.isGroupsEnabled) {
|
||||||
promises.push(this.props.actions.searchAssociatedGroupsForReference(term, this.props.channel.team_id, this.props.channel.id, opts));
|
promises.push(props.actions.searchAssociatedGroupsForReference(term, props.channel.team_id, props.channel.id, opts));
|
||||||
}
|
}
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
this.setUsersLoadingState(false);
|
setUsersLoadingState(false);
|
||||||
},
|
},
|
||||||
Constants.SEARCH_TIMEOUT_MILLISECONDS,
|
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) {
|
if (!option) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (this.isUser(option)) {
|
if (isUser(option)) {
|
||||||
return option.username;
|
return option.username;
|
||||||
}
|
}
|
||||||
return option.name;
|
return option.name;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
private filterOutDeletedAndExcludedAndNotInTeamUsers = (users: UserProfile[], excludeUserIds: Set<string>): UserProfileValue[] => {
|
// Render option for multiselect
|
||||||
return users.filter((user) => {
|
const renderOption = useCallback((option: UserProfileValue | GroupValue, isSelected: boolean, onAdd: (option: UserProfileValue | GroupValue) => void, onMouseMove: (option: UserProfileValue | GroupValue) => void) => {
|
||||||
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) => {
|
|
||||||
let rowSelected = '';
|
let rowSelected = '';
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
rowSelected = 'more-modal__row--selected';
|
rowSelected = 'more-modal__row--selected';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isUser(option)) {
|
if (isUser(option)) {
|
||||||
const ProfilesInGroup = this.props.profilesInCurrentChannel.map((user) => user.id);
|
const ProfilesInGroup = props.profilesInCurrentChannel.map((user) => user.id);
|
||||||
|
|
||||||
const userMapping: Record<string, string> = {};
|
const userMapping: Record<string, string> = {};
|
||||||
for (let i = 0; i < ProfilesInGroup.length; i++) {
|
for (let i = 0; i < ProfilesInGroup.length; i++) {
|
||||||
userMapping[ProfilesInGroup[i]] = 'Already in channel';
|
userMapping[ProfilesInGroup[i]] = 'Already in channel';
|
||||||
}
|
}
|
||||||
const displayName = displayUsername(option, this.props.teammateNameDisplaySetting);
|
const displayName = displayUsername(option, props.teammateNameDisplaySetting);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option.id}
|
||||||
ref={isSelected ? this.selectedItemRef : option.id}
|
ref={isSelected ? selectedItemRef : undefined}
|
||||||
className={'more-modal__row clickable ' + rowSelected}
|
className={'more-modal__row clickable ' + rowSelected}
|
||||||
onClick={() => onAdd(option)}
|
onClick={() => onAdd(option)}
|
||||||
onMouseMove={() => onMouseMove(option)}
|
onMouseMove={() => onMouseMove(option)}
|
||||||
>
|
>
|
||||||
<ProfilePicture
|
<ProfilePicture
|
||||||
src={Client4.getProfilePictureUrl(option.id, option.last_picture_update)}
|
src={Client4.getProfilePictureUrl(option.id, option.last_picture_update)}
|
||||||
status={this.props.userStatuses[option.id]}
|
status={props.userStatuses[option.id]}
|
||||||
size='md'
|
size='md'
|
||||||
username={option.username}
|
username={option.username}
|
||||||
/>
|
/>
|
||||||
@@ -408,15 +362,15 @@ export class ChannelInviteModal extends React.PureComponent<Props, State> {
|
|||||||
{displayName}
|
{displayName}
|
||||||
{option.is_bot && <BotTag/>}
|
{option.is_bot && <BotTag/>}
|
||||||
{isGuest(option.roles) && <GuestTag className='popoverlist'/>}
|
{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}
|
{'@'}{option.username}
|
||||||
</UsernameSpan>
|
</span>
|
||||||
}
|
}
|
||||||
<UserMappingSpan
|
<span
|
||||||
className='light'
|
className='channel-invite__user-mapping light'
|
||||||
>
|
>
|
||||||
{userMapping[option.id]}
|
{userMapping[option.id]}
|
||||||
</UserMappingSpan>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,146 +396,226 @@ export class ChannelInviteModal extends React.PureComponent<Props, State> {
|
|||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
rowSelected={rowSelected}
|
rowSelected={rowSelected}
|
||||||
onMouseMove={onMouseMove}
|
onMouseMove={onMouseMove}
|
||||||
selectedItemRef={this.selectedItemRef}
|
selectedItemRef={selectedItemRef}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
}, [props.profilesInCurrentChannel, props.teammateNameDisplaySetting, props.userStatuses]);
|
||||||
|
|
||||||
public render = (): JSX.Element => {
|
// Initial data loading - only run when channel changes or component mounts
|
||||||
let inviteError = null;
|
useEffect(() => {
|
||||||
if (this.state.inviteError) {
|
props.actions.getProfilesNotInChannel(props.channel.team_id, props.channel.id, props.channel.group_constrained, 0).then(() => {
|
||||||
inviteError = (<label className='has-error control-label'>{this.state.inviteError}</label>);
|
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'});
|
if (!isEqual(computedOptions, groupAndUserOptions)) {
|
||||||
const buttonSubmitLoadingText = defineMessage({id: 'multiselect.adding', defaultMessage: 'Adding...'});
|
if (userIds.length > 0) {
|
||||||
|
props.actions.getTeamMembersByIds(props.channel.team_id, userIds);
|
||||||
|
}
|
||||||
|
setGroupAndUserOptions(computedOptions);
|
||||||
|
}
|
||||||
|
}, [computedOptions, props.actions, props.channel.team_id]);
|
||||||
|
|
||||||
const closeMembersInviteModal = () => {
|
// Cleanup on unmount
|
||||||
this.props.actions.closeModal(ModalIdentifiers.CHANNEL_INVITE);
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTimeout(searchTimeoutId.current);
|
||||||
};
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const InviteModalLink = (props: {inviteAsGuest?: boolean; children: React.ReactNode; id?: string}) => {
|
// Render the component
|
||||||
return (
|
const buttonSubmitText = defineMessage({id: 'multiselect.add', defaultMessage: 'Add'});
|
||||||
<ToggleModalButton
|
const buttonSubmitLoadingText = defineMessage({id: 'multiselect.adding', defaultMessage: 'Adding...'});
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const customNoOptionsMessage = (
|
const closeMembersInviteModal = () => {
|
||||||
<div
|
props.actions.closeModal(ModalIdentifiers.CHANNEL_INVITE);
|
||||||
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 InviteModalLink = (props: {inviteAsGuest?: boolean; children: React.ReactNode; id?: string}) => {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<ToggleModalButton
|
||||||
id='addUsersToChannelModal'
|
className={`${props.inviteAsGuest ? 'invite-as-guest' : ''} btn btn-link`}
|
||||||
dialogClassName='a11y__modal channel-invite'
|
modalId={ModalIdentifiers.INVITATION}
|
||||||
show={this.state.show}
|
dialogType={InvitationModal}
|
||||||
onHide={this.onHide}
|
dialogProps={{
|
||||||
onExited={this.props.onExited}
|
channelToInvite: channel,
|
||||||
role='none'
|
initialValue: term,
|
||||||
aria-labelledby='channelInviteModalLabel'
|
inviteAsGuest: props.inviteAsGuest,
|
||||||
|
focusOriginElement: 'customNoOptionsMessageLink',
|
||||||
|
}}
|
||||||
|
onClick={closeMembersInviteModal}
|
||||||
|
id={props.id}
|
||||||
>
|
>
|
||||||
<Modal.Header
|
{props.children}
|
||||||
id='channelInviteModalLabel'
|
</ToggleModalButton>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
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 React, {useCallback, useEffect, useState} from 'react';
|
||||||
import {FormattedMessage, useIntl} from 'react-intl';
|
import {FormattedMessage, useIntl} from 'react-intl';
|
||||||
import {useHistory} from 'react-router-dom';
|
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 type {UserProfile} from '@mattermost/types/users';
|
||||||
|
|
||||||
import {ProfilesInChannelSortBy} from 'mattermost-redux/actions/users';
|
import {ProfilesInChannelSortBy} from 'mattermost-redux/actions/users';
|
||||||
|
|
||||||
import AlertBanner from 'components/alert_banner';
|
import AlertBanner from 'components/alert_banner';
|
||||||
import ChannelInviteModal from 'components/channel_invite_modal';
|
import ChannelInviteModal from 'components/channel_invite_modal';
|
||||||
|
import useAccessControlAttributes, {EntityType} from 'components/common/hooks/useAccessControlAttributes';
|
||||||
import ExternalLink from 'components/external_link';
|
import ExternalLink from 'components/external_link';
|
||||||
import MoreDirectChannels from 'components/more_direct_channels';
|
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';
|
import Constants, {ModalIdentifiers} from 'utils/constants';
|
||||||
|
|
||||||
@@ -23,21 +25,13 @@ import type {ModalData} from 'types/actions';
|
|||||||
|
|
||||||
import ActionBar from './action_bar';
|
import ActionBar from './action_bar';
|
||||||
import Header from './header';
|
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';
|
import SearchBar from './search';
|
||||||
|
|
||||||
const USERS_PER_PAGE = 100;
|
import './channel_members_rhs.scss';
|
||||||
export interface ChannelMember {
|
|
||||||
user: UserProfile;
|
|
||||||
membership?: ChannelMembership;
|
|
||||||
status?: string;
|
|
||||||
displayName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MembersContainer = styled.div`
|
const USERS_PER_PAGE = 100;
|
||||||
flex: 1 1 auto;
|
|
||||||
padding: 0 4px 16px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
channel: Channel;
|
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({
|
export default function ChannelMembersRHS({
|
||||||
channel,
|
channel,
|
||||||
currentUserIsChannelAdmin,
|
currentUserIsChannelAdmin,
|
||||||
@@ -94,6 +77,21 @@ export default function ChannelMembersRHS({
|
|||||||
const [isNextPageLoading, setIsNextPageLoading] = useState(false);
|
const [isNextPageLoading, setIsNextPageLoading] = useState(false);
|
||||||
const {formatMessage} = useIntl();
|
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 searching = searchTerms !== '';
|
||||||
|
|
||||||
const isDefaultChannel = channel.name === Constants.DEFAULT_CHANNEL;
|
const isDefaultChannel = channel.name === Constants.DEFAULT_CHANNEL;
|
||||||
@@ -134,17 +132,17 @@ export default function ChannelMembersRHS({
|
|||||||
|
|
||||||
listcp.push({
|
listcp.push({
|
||||||
type: ListItemType.FirstSeparator,
|
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) {
|
} else if (!memberDone && member.membership?.scheme_admin === false) {
|
||||||
listcp.push({
|
listcp.push({
|
||||||
type: ListItemType.Separator,
|
type: ListItemType.Separator,
|
||||||
data: <MemberListSeparator>
|
data: <div className='channel-members-rhs__member-list-separator'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='channel_members_rhs.list.channel_members_title'
|
id='channel_members_rhs.list.channel_members_title'
|
||||||
defaultMessage='MEMBERS'
|
defaultMessage='MEMBERS'
|
||||||
/>
|
/>
|
||||||
</MemberListSeparator>,
|
</div>,
|
||||||
});
|
});
|
||||||
memberDone = true;
|
memberDone = true;
|
||||||
}
|
}
|
||||||
@@ -226,7 +224,7 @@ export default function ChannelMembersRHS({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id='rhsContainer'
|
id='rhsContainer'
|
||||||
className='sidebar-right__body'
|
className='sidebar-right__body channel-members-rhs'
|
||||||
>
|
>
|
||||||
|
|
||||||
<Header
|
<Header
|
||||||
@@ -235,6 +233,34 @@ export default function ChannelMembersRHS({
|
|||||||
onClose={actions.closeRightHandSide}
|
onClose={actions.closeRightHandSide}
|
||||||
goBack={actions.goBack}
|
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
|
<ActionBar
|
||||||
channelType={channel.type}
|
channelType={channel.type}
|
||||||
@@ -250,7 +276,7 @@ export default function ChannelMembersRHS({
|
|||||||
|
|
||||||
{/* Users with user management permissions have special restrictions in the default channel */}
|
{/* Users with user management permissions have special restrictions in the default channel */}
|
||||||
{(editing && isDefaultChannel && !currentUserIsChannelAdmin) && (
|
{(editing && isDefaultChannel && !currentUserIsChannelAdmin) && (
|
||||||
<AlertContainer>
|
<div className='channel-members-rhs__alert-container'>
|
||||||
<AlertBanner
|
<AlertBanner
|
||||||
mode='info'
|
mode='info'
|
||||||
variant='app'
|
variant='app'
|
||||||
@@ -268,7 +294,7 @@ export default function ChannelMembersRHS({
|
|||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</AlertContainer>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
@@ -278,7 +304,7 @@ export default function ChannelMembersRHS({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MembersContainer>
|
<div className='channel-members-rhs__members-container'>
|
||||||
{channelMembers.length > 0 && (
|
{channelMembers.length > 0 && (
|
||||||
<MemberList
|
<MemberList
|
||||||
searchTerms={searchTerms}
|
searchTerms={searchTerms}
|
||||||
@@ -291,26 +317,7 @@ export default function ChannelMembersRHS({
|
|||||||
isNextPageLoading={isNextPageLoading}
|
isNextPageLoading={isNextPageLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</MembersContainer>
|
</div>
|
||||||
</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 type {GlobalState} from 'types/store';
|
||||||
|
|
||||||
import RHS from './channel_members_rhs';
|
import ChannelMembersRHS from './channel_members_rhs';
|
||||||
import type {Props, ChannelMember} from './channel_members_rhs';
|
import type {Props} from './channel_members_rhs';
|
||||||
|
import type {ChannelMember} from './member_list';
|
||||||
|
|
||||||
const buildProfileList = (
|
const buildProfileList = (
|
||||||
profilesInCurrentChannel: UserProfile[],
|
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 classNames from 'classnames';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {FormattedMessage, useIntl} from 'react-intl';
|
import {FormattedMessage, useIntl} from 'react-intl';
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
import type {Channel} from '@mattermost/types/channels';
|
import type {Channel} from '@mattermost/types/channels';
|
||||||
import type {UserProfile} from '@mattermost/types/users';
|
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 GuestTag from 'components/widgets/tag/guest_tag';
|
||||||
import WithTooltip from 'components/with_tooltip';
|
import WithTooltip from 'components/with_tooltip';
|
||||||
|
|
||||||
import type {ChannelMember} from './channel_members_rhs';
|
import type {ChannelMember as ChannelMemberType} from './member_list';
|
||||||
|
|
||||||
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;
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
member: ChannelMember;
|
member: ChannelMemberType;
|
||||||
index: number;
|
index: number;
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
editing: boolean;
|
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 {formatMessage} = useIntl();
|
||||||
|
|
||||||
const userProfileSrc = Client4.getProfilePictureUrl(member.user.id, member.user.last_picture_update);
|
const userProfileSrc = Client4.getProfilePictureUrl(member.user.id, member.user.last_picture_update);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className='channel-members-rhs__member'
|
||||||
style={{height: '48px'}}
|
style={{height: '48px'}}
|
||||||
data-testid={`memberline-${member.user.id}`}
|
data-testid={`memberline-${member.user.id}`}
|
||||||
>
|
>
|
||||||
<span className='ProfileSpan'>
|
<span className='ProfileSpan'>
|
||||||
<Avatar>
|
<div className='channel-members-rhs__avatar'>
|
||||||
<ProfilePicture
|
<ProfilePicture
|
||||||
size='sm'
|
size='sm'
|
||||||
status={member.status}
|
status={member.status}
|
||||||
@@ -125,27 +53,27 @@ const Member = ({className, channel, member, index, totalUsers, editing, actions
|
|||||||
username={member.displayName}
|
username={member.displayName}
|
||||||
src={userProfileSrc}
|
src={userProfileSrc}
|
||||||
/>
|
/>
|
||||||
</Avatar>
|
</div>
|
||||||
<ProfilePopover
|
<ProfilePopover
|
||||||
triggerComponentClass='profileSpan_userInfo'
|
triggerComponentClass='profileSpan_userInfo'
|
||||||
userId={member.user.id}
|
userId={member.user.id}
|
||||||
src={userProfileSrc}
|
src={userProfileSrc}
|
||||||
hideStatus={member.user.is_bot}
|
hideStatus={member.user.is_bot}
|
||||||
>
|
>
|
||||||
<DisplayName>
|
<span className='channel-members-rhs__display-name'>
|
||||||
{member.displayName}
|
{member.displayName}
|
||||||
{isGuest(member.user.roles) && <GuestTag/>}
|
{isGuest(member.user.roles) && <GuestTag/>}
|
||||||
{member.user.remote_id &&
|
{member.user.remote_id &&
|
||||||
(
|
(
|
||||||
<SharedIcon>
|
<span className='channel-members-rhs__shared-icon'>
|
||||||
<SharedChannelIndicator
|
<SharedChannelIndicator
|
||||||
withTooltip={true}
|
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
|
<CustomStatusEmoji
|
||||||
userID={member.user.id}
|
userID={member.user.id}
|
||||||
@@ -164,8 +92,8 @@ const Member = ({className, channel, member, index, totalUsers, editing, actions
|
|||||||
</ProfilePopover>
|
</ProfilePopover>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<RoleChooser
|
<div
|
||||||
className={classNames({editing}, 'member-role-chooser')}
|
className={classNames('channel-members-rhs__role-chooser', {editing})}
|
||||||
data-testid='rolechooser'
|
data-testid='rolechooser'
|
||||||
>
|
>
|
||||||
{member.membership && (
|
{member.membership && (
|
||||||
@@ -195,7 +123,7 @@ const Member = ({className, channel, member, index, totalUsers, editing, actions
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</RoleChooser>
|
</div>
|
||||||
{!editing && (
|
{!editing && (
|
||||||
<WithTooltip
|
<WithTooltip
|
||||||
title={formatMessage({
|
title={formatMessage({
|
||||||
@@ -203,51 +131,16 @@ const Member = ({className, channel, member, index, totalUsers, editing, actions
|
|||||||
defaultMessage: 'Send message',
|
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'/>
|
<i className='icon icon-send'/>
|
||||||
</SendMessage>
|
</button>
|
||||||
</WithTooltip>
|
</WithTooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default styled(Member)`
|
export default 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;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -12,20 +12,20 @@ import type {UserProfile} from '@mattermost/types/users';
|
|||||||
|
|
||||||
import Member from './member';
|
import Member from './member';
|
||||||
|
|
||||||
interface ChannelMember {
|
export interface ChannelMember {
|
||||||
user: UserProfile;
|
user: UserProfile;
|
||||||
membership?: ChannelMembership;
|
membership?: ChannelMembership;
|
||||||
status?: string;
|
status?: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ListItemType {
|
export enum ListItemType {
|
||||||
Member = 'member',
|
Member = 'member',
|
||||||
FirstSeparator = 'first-separator',
|
FirstSeparator = 'first-separator',
|
||||||
Separator = 'separator',
|
Separator = 'separator',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListItem {
|
export interface ListItem {
|
||||||
type: ListItemType;
|
type: ListItemType;
|
||||||
data: ChannelMember | JSX.Element;
|
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.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.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.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.posted": "Posted",
|
||||||
"channel_loader.postedImage": " posted an image",
|
"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).",
|
"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_channel_member": "Member",
|
||||||
"channel_members_rhs.member.select_role_guest": "Guest",
|
"channel_members_rhs.member.select_role_guest": "Guest",
|
||||||
"channel_members_rhs.member.send_message": "Send message",
|
"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.aria.cancel_search_button": "cancel members search",
|
||||||
"channel_members_rhs.search_bar.placeholder": "Search members",
|
"channel_members_rhs.search_bar.placeholder": "Search members",
|
||||||
"channel_menu.bookmarks": "Bookmarks Bar",
|
"channel_menu.bookmarks": "Bookmarks Bar",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import type {AnyAction} from 'redux';
|
import type {AnyAction} from 'redux';
|
||||||
import {batchActions} from 'redux-batched-actions';
|
import {batchActions} from 'redux-batched-actions';
|
||||||
|
|
||||||
|
import type {AccessControlAttributes} from '@mattermost/types/access_control';
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
ChannelNotifyProps,
|
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 {
|
export default {
|
||||||
selectChannel,
|
selectChannel,
|
||||||
createChannel,
|
createChannel,
|
||||||
@@ -1483,4 +1499,5 @@ export default {
|
|||||||
membersMinusGroupMembers,
|
membersMinusGroupMembers,
|
||||||
getChannelModerations,
|
getChannelModerations,
|
||||||
getChannelMemberCountsByGroup,
|
getChannelMemberCountsByGroup,
|
||||||
|
getChannelAccessControlAttributes,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
/* eslint-disable max-lines */
|
/* 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 {ClusterInfo, AnalyticsRow, SchemaMigration, LogFilterQuery} from '@mattermost/types/admin';
|
||||||
import type {AppBinding, AppCallRequest, AppCallResponse} from '@mattermost/types/apps';
|
import type {AppBinding, AppCallRequest, AppCallResponse} from '@mattermost/types/apps';
|
||||||
import type {Audit} from '@mattermost/types/audits';
|
import type {Audit} from '@mattermost/types/audits';
|
||||||
@@ -4512,6 +4512,13 @@ export default class Client4 {
|
|||||||
{method: 'post', body: JSON.stringify({expression})},
|
{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) {
|
export function parseAndMergeNestedHeaders(originalHeaders: any) {
|
||||||
|
|||||||
@@ -53,11 +53,6 @@ export type AccessControlTestResult = {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccessControlEntity = {
|
|
||||||
name: string;
|
|
||||||
attributes: AccessControlAttribute[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AccessControlAttribute = {
|
export type AccessControlAttribute = {
|
||||||
name: string;
|
name: string;
|
||||||
values: string[];
|
values: string[];
|
||||||
@@ -73,3 +68,19 @@ export type AccessControlVisualASTNode = {
|
|||||||
value: any;
|
value: any;
|
||||||
value_type: number;
|
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