MM-60640: [Shared Channels] Display remotes' names in Shared With tooltip (#30886)
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
bd16f4f9bf
Коммит
bd5ca1c07e
@@ -2515,3 +2515,41 @@
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
"/api/v4/sharedchannels/{channel_id}/remotes":
|
||||
get:
|
||||
tags:
|
||||
- channels
|
||||
summary: Get remote clusters for a shared channel
|
||||
description: |
|
||||
Gets the remote clusters information for a shared channel.
|
||||
|
||||
__Minimum server version__: 10.10
|
||||
|
||||
##### Permissions
|
||||
Must be authenticated and have the `read_channel` permission for the channel.
|
||||
operationId: GetSharedChannelRemotes
|
||||
parameters:
|
||||
- name: channel_id
|
||||
in: path
|
||||
description: Channel GUID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Remote clusters retrieval successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/RemoteClusterInfo"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
func (api *API) InitSharedChannels() {
|
||||
api.BaseRoutes.SharedChannels.Handle("/{team_id:[A-Za-z0-9]+}", api.APISessionRequired(getSharedChannels)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.SharedChannels.Handle("/remote_info/{remote_id:[A-Za-z0-9]+}", api.APISessionRequired(getRemoteClusterInfo)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.SharedChannels.Handle("/{channel_id:[A-Za-z0-9]+}/remotes", api.APISessionRequired(getSharedChannelRemotes)).Methods(http.MethodGet)
|
||||
|
||||
api.BaseRoutes.SharedChannelRemotes.Handle("", api.APISessionRequired(getSharedChannelRemotesByRemoteCluster)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.ChannelForRemote.Handle("/invite", api.APISessionRequired(inviteRemoteClusterToChannel)).Methods(http.MethodPost)
|
||||
@@ -246,3 +247,51 @@ func uninviteRemoteClusterToChannel(c *Context, w http.ResponseWriter, r *http.R
|
||||
auditRec.Success()
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
// getSharedChannelRemotes returns info about remote clusters for a shared channel
|
||||
func getSharedChannelRemotes(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// make sure remote cluster service is enabled.
|
||||
if _, appErr := c.App.GetRemoteClusterService(); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
|
||||
c.SetPermissionError(model.PermissionReadChannel)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the remotes status
|
||||
remoteStatuses, err := c.App.GetSharedChannelRemotesStatus(c.Params.ChannelId)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("getSharedChannelRemotes", "api.command_share.fetch_remote_status.error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
// For each remote status, get the RemoteClusterInfo
|
||||
remoteInfos := make([]*model.RemoteClusterInfo, 0, len(remoteStatuses))
|
||||
for _, status := range remoteStatuses {
|
||||
// Use GetRemoteCluster to get the full remote cluster
|
||||
remoteCluster, appErr := c.App.GetRemoteCluster(status.ChannelId, false)
|
||||
if appErr == nil && remoteCluster != nil {
|
||||
info := remoteCluster.ToRemoteClusterInfo()
|
||||
remoteInfos = append(remoteInfos, &info)
|
||||
} else {
|
||||
// If we can't find the detailed info, create a basic RemoteClusterInfo from the status
|
||||
remoteInfos = append(remoteInfos, &model.RemoteClusterInfo{
|
||||
Name: status.ChannelId,
|
||||
DisplayName: status.DisplayName,
|
||||
LastPingAt: status.LastPingAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(remoteInfos); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
123
server/channels/api4/shared_channel_remotes_test.go
Обычный файл
123
server/channels/api4/shared_channel_remotes_test.go
Обычный файл
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
func TestGetSharedChannelRemotes(t *testing.T) {
|
||||
th := setupForSharedChannels(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
// Create remote clusters
|
||||
remote1 := &model.RemoteCluster{
|
||||
Name: "remote1",
|
||||
DisplayName: "Remote Cluster 1",
|
||||
SiteURL: "http://example.com",
|
||||
CreatorId: th.BasicUser.Id,
|
||||
Token: model.NewId(),
|
||||
LastPingAt: model.GetMillis(),
|
||||
}
|
||||
remote1, appErr := th.App.AddRemoteCluster(remote1)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
remote2 := &model.RemoteCluster{
|
||||
Name: "remote2",
|
||||
DisplayName: "Remote Cluster 2",
|
||||
SiteURL: "http://example.org",
|
||||
CreatorId: th.BasicUser.Id,
|
||||
Token: model.NewId(),
|
||||
LastPingAt: model.GetMillis(),
|
||||
}
|
||||
remote2, appErr = th.App.AddRemoteCluster(remote2)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Create shared channel
|
||||
channel1 := th.CreateChannelWithClientAndTeam(th.Client, model.ChannelTypeOpen, th.BasicTeam.Id)
|
||||
sc1 := &model.SharedChannel{
|
||||
ChannelId: channel1.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Home: true,
|
||||
ReadOnly: false,
|
||||
ShareName: channel1.Name,
|
||||
ShareDisplayName: channel1.DisplayName,
|
||||
SharePurpose: channel1.Purpose,
|
||||
ShareHeader: channel1.Header,
|
||||
CreatorId: th.BasicUser.Id,
|
||||
}
|
||||
|
||||
_, sErr := th.App.ShareChannel(th.Context, sc1)
|
||||
require.NoError(t, sErr)
|
||||
|
||||
// Add remotes to channel1
|
||||
scr1 := &model.SharedChannelRemote{
|
||||
ChannelId: sc1.ChannelId,
|
||||
RemoteId: remote1.RemoteId,
|
||||
CreatorId: th.BasicUser.Id,
|
||||
IsInviteAccepted: true,
|
||||
IsInviteConfirmed: true,
|
||||
}
|
||||
_, sErr = th.App.SaveSharedChannelRemote(scr1)
|
||||
require.NoError(t, sErr)
|
||||
|
||||
scr2 := &model.SharedChannelRemote{
|
||||
ChannelId: sc1.ChannelId,
|
||||
RemoteId: remote2.RemoteId,
|
||||
CreatorId: th.BasicUser.Id,
|
||||
IsInviteAccepted: true,
|
||||
IsInviteConfirmed: true,
|
||||
}
|
||||
_, sErr = th.App.SaveSharedChannelRemote(scr2)
|
||||
require.NoError(t, sErr)
|
||||
|
||||
// Test the API endpoint
|
||||
url := fmt.Sprintf("/sharedchannels/%s/remotes", channel1.Id)
|
||||
resp, err := th.Client.DoAPIGet(context.Background(), url, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
var result []*model.RemoteClusterInfo
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify response
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, result, 2)
|
||||
|
||||
// Sort remote infos by display name for consistent testing
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].DisplayName < result[j].DisplayName
|
||||
})
|
||||
|
||||
// Verify the RemoteClusterInfo objects contain the expected data
|
||||
assert.Equal(t, remote1.DisplayName, result[0].DisplayName)
|
||||
assert.Equal(t, remote2.DisplayName, result[1].DisplayName)
|
||||
|
||||
// Should also contain other fields
|
||||
assert.NotEmpty(t, result[0].Name)
|
||||
assert.NotEmpty(t, result[1].Name)
|
||||
assert.NotZero(t, result[0].LastPingAt)
|
||||
assert.NotZero(t, result[1].LastPingAt)
|
||||
|
||||
// Test access control - user without permissions should not be able to access
|
||||
user2 := th.CreateUser()
|
||||
_, err = th.Client.Logout(context.Background())
|
||||
require.NoError(t, err)
|
||||
_, _, err = th.Client.Login(context.Background(), user2.Email, user2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err = th.Client.DoAPIGet(context.Background(), url, "")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
@@ -67,6 +67,7 @@ exports[`components/ChannelHeader should match snapshot with last active display
|
||||
"username": "some-user",
|
||||
}
|
||||
}
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
@@ -324,6 +325,7 @@ exports[`components/ChannelHeader should match snapshot with no last active disp
|
||||
"username": "some-user",
|
||||
}
|
||||
}
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
@@ -512,7 +514,9 @@ exports[`components/ChannelHeader should render active channel files 1`] = `
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
@@ -668,7 +672,9 @@ exports[`components/ChannelHeader should render active flagged posts 1`] = `
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
@@ -824,7 +830,9 @@ exports[`components/ChannelHeader should render active mentions posts 1`] = `
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
@@ -980,7 +988,9 @@ exports[`components/ChannelHeader should render active pinned posts 1`] = `
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
@@ -1136,7 +1146,9 @@ exports[`components/ChannelHeader should render archived view 1`] = `
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
@@ -1292,7 +1304,9 @@ exports[`components/ChannelHeader should render correct menu when muted 1`] = `
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
@@ -1468,7 +1482,9 @@ exports[`components/ChannelHeader should render not active channel files 1`] = `
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
@@ -1666,6 +1682,7 @@ exports[`components/ChannelHeader should render properly when custom status is e
|
||||
"username": "some-user",
|
||||
}
|
||||
}
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
@@ -1919,6 +1936,7 @@ exports[`components/ChannelHeader should render properly when custom status is s
|
||||
"username": "some-user",
|
||||
}
|
||||
}
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
@@ -2148,7 +2166,9 @@ exports[`components/ChannelHeader should render properly when empty 1`] = `
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
@@ -2304,7 +2324,9 @@ exports[`components/ChannelHeader should render properly when populated 1`] = `
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
@@ -2460,7 +2482,9 @@ exports[`components/ChannelHeader should render properly when populated with cha
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
@@ -2637,7 +2661,9 @@ exports[`components/ChannelHeader should render shared view 1`] = `
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
@@ -2749,7 +2775,9 @@ exports[`components/ChannelHeader should render the pinned icon with the pinned
|
||||
<div
|
||||
className="channel-header__title dropdown"
|
||||
>
|
||||
<Memo(ChannelHeaderTitle) />
|
||||
<Memo(ChannelHeaderTitle)
|
||||
remoteNames={Array []}
|
||||
/>
|
||||
<div
|
||||
className="channel-header__icons"
|
||||
>
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('components/ChannelHeader', () => {
|
||||
getCustomEmojisInText: jest.fn(),
|
||||
updateChannelNotifyProps: jest.fn(),
|
||||
showChannelMembers: jest.fn(),
|
||||
fetchChannelRemotes: jest.fn(),
|
||||
},
|
||||
teamId: 'team_id',
|
||||
channel: TestHelper.getChannelMock({}),
|
||||
@@ -49,6 +50,7 @@ describe('components/ChannelHeader', () => {
|
||||
'hour',
|
||||
],
|
||||
hideGuestTags: false,
|
||||
remoteNames: [],
|
||||
sharedChannelsPluginsEnabled: false,
|
||||
intl: {
|
||||
formatMessage: jest.fn(({id, defaultMessage}) => defaultMessage || id),
|
||||
|
||||
@@ -40,6 +40,12 @@ class ChannelHeader extends React.PureComponent<Props> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.actions.getCustomEmojisInText(this.props.channel ? this.props.channel.header : '');
|
||||
|
||||
// Fetch remote names for shared channels on initial mount
|
||||
if (this.props.channel?.shared) {
|
||||
// Don't force refresh on initial load, use cached data if available
|
||||
this.props.actions.fetchChannelRemotes(this.props.channel.id);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
@@ -48,6 +54,17 @@ class ChannelHeader extends React.PureComponent<Props> {
|
||||
if (header !== prevHeader) {
|
||||
this.props.actions.getCustomEmojisInText(header);
|
||||
}
|
||||
|
||||
// Fetch remote names when channel changes or when a channel becomes shared
|
||||
if (this.props.channel?.shared) {
|
||||
if (this.props.channel.id !== prevProps.channel?.id) {
|
||||
// For regular channel changes, use cached data if available
|
||||
this.props.actions.fetchChannelRemotes(this.props.channel.id);
|
||||
} else if (this.props.channel.shared !== prevProps.channel?.shared) {
|
||||
// Only force refresh when a channel's shared status changes
|
||||
this.props.actions.fetchChannelRemotes(this.props.channel.id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unmute = () => {
|
||||
@@ -337,6 +354,7 @@ class ChannelHeader extends React.PureComponent<Props> {
|
||||
<ChannelHeaderTitle
|
||||
dmUser={dmUser}
|
||||
gmMembers={gmMembers}
|
||||
remoteNames={this.props.remoteNames}
|
||||
/>
|
||||
<div
|
||||
className='channel-header__icons'
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {mount} from 'enzyme';
|
||||
import React from 'react';
|
||||
import {Provider} from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
|
||||
import {fetchChannelRemotes} from 'packages/mattermost-redux/src/actions/shared_channels';
|
||||
import {getRemoteNamesForChannel} from 'packages/mattermost-redux/src/selectors/entities/shared_channels';
|
||||
|
||||
import ChannelHeaderTitle from './channel_header_title';
|
||||
|
||||
// Mock the child components to avoid Redux hook issues
|
||||
jest.mock('./channel_header_title_favorite', () => {
|
||||
return () => <div id='mock-favorite'/>;
|
||||
});
|
||||
|
||||
jest.mock('./channel_header_title_direct', () => {
|
||||
return () => <div id='mock-direct'/>;
|
||||
});
|
||||
|
||||
jest.mock('./channel_header_title_group', () => {
|
||||
return () => <div id='mock-group'/>;
|
||||
});
|
||||
|
||||
jest.mock('../channel_header_menu/channel_header_menu', () => {
|
||||
return () => <div id='mock-header-menu'/>;
|
||||
});
|
||||
|
||||
// Mock the modules causing issues
|
||||
jest.mock('selectors/rhs', () => ({
|
||||
getRhsState: jest.fn(),
|
||||
getSelectedPost: jest.fn(),
|
||||
getSelectedChannel: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock channel sidebar selector
|
||||
jest.mock('selectors/views/channel_sidebar', () => ({
|
||||
isChannelSelected: jest.fn(),
|
||||
getAutoSortedCategoryIds: jest.fn(),
|
||||
getDraggingState: jest.fn(),
|
||||
}));
|
||||
|
||||
// Need to mock this for createSelectorCreator
|
||||
jest.mock('mattermost-redux/utils/helpers', () => ({
|
||||
memoizeResult: jest.fn(),
|
||||
defaultMemoize: jest.fn(),
|
||||
createIdsSelector: jest.fn(),
|
||||
createShallowSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock create_selector
|
||||
jest.mock('mattermost-redux/selectors/create_selector', () => ({
|
||||
createSelector: jest.fn((...args) => {
|
||||
const last = args[args.length - 1];
|
||||
return jest.fn(last);
|
||||
}),
|
||||
createSelectorCreator: jest.fn(() => {
|
||||
return jest.fn((...args) => {
|
||||
const last = args[args.length - 1];
|
||||
return jest.fn(last);
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
|
||||
getCurrentChannel: jest.fn(),
|
||||
isCurrentChannelFavorite: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('packages/mattermost-redux/src/selectors/entities/shared_channels', () => ({
|
||||
getRemoteNamesForChannel: jest.fn(),
|
||||
}));
|
||||
|
||||
// Use a mock name prefix to avoid the Jest variable scoping issue
|
||||
jest.mock('packages/mattermost-redux/src/actions/shared_channels', () => ({
|
||||
fetchChannelRemotes: jest.fn(() => ({type: 'MOCK_ACTION'})),
|
||||
}));
|
||||
|
||||
// Also mock for the actual path used in the test
|
||||
jest.mock('mattermost-redux/actions/shared_channels', () => ({
|
||||
fetchChannelRemotes: jest.fn(() => ({type: 'MOCK_ACTION'})),
|
||||
}));
|
||||
|
||||
describe('components/channel_header/ChannelHeaderTitle', () => {
|
||||
const mockStore = configureStore();
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should not fetch shared channels for non-shared channels', () => {
|
||||
// Mock non-shared channel
|
||||
const channel = {
|
||||
id: 'channel_id',
|
||||
team_id: 'team_id',
|
||||
display_name: 'Test Channel',
|
||||
type: 'O',
|
||||
shared: false,
|
||||
};
|
||||
|
||||
(getCurrentChannel as jest.Mock).mockReturnValue(channel);
|
||||
(getRemoteNamesForChannel as jest.Mock).mockReturnValue([]);
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
mount(
|
||||
<Provider store={store}>
|
||||
<ChannelHeaderTitle/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(fetchChannelRemotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should fetch shared channels data when channel is shared', () => {
|
||||
// We don't directly need to test fetchChannelRemoteNames in this test
|
||||
// since our ChannelHeaderTitle doesn't directly call it
|
||||
// That would be properly tested in the channel_header.test.tsx file
|
||||
|
||||
// Mock shared channel
|
||||
const channel = {
|
||||
id: 'channel_id',
|
||||
team_id: 'team_id',
|
||||
display_name: 'Test Channel',
|
||||
type: 'O',
|
||||
shared: true,
|
||||
};
|
||||
|
||||
(getCurrentChannel as jest.Mock).mockReturnValue(channel);
|
||||
(getRemoteNamesForChannel as jest.Mock).mockReturnValue([]);
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
mount(
|
||||
<Provider store={store}>
|
||||
<ChannelHeaderTitle remoteNames={[]}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Instead of testing the action being called, we can test that the component
|
||||
// renders correctly with a shared channel
|
||||
// If we were doing full end-to-end testing, we'd need to fully
|
||||
// test the Redux action flow, but that's beyond this component test
|
||||
});
|
||||
|
||||
test('should not fetch shared channels data when data already exists', () => {
|
||||
// Similar to the test above, we're testing the component rendering
|
||||
// rather than the action which is called by a parent component
|
||||
|
||||
// Mock shared channel
|
||||
const channel = {
|
||||
id: 'channel_id',
|
||||
team_id: 'team_id',
|
||||
display_name: 'Test Channel',
|
||||
type: 'O',
|
||||
shared: true,
|
||||
};
|
||||
|
||||
(getCurrentChannel as jest.Mock).mockReturnValue(channel);
|
||||
(getRemoteNamesForChannel as jest.Mock).mockReturnValue(['Remote 1', 'Remote 2']); // Data exists
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
mount(
|
||||
<Provider store={store}>
|
||||
<ChannelHeaderTitle remoteNames={['Remote 1', 'Remote 2']}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Again, we'd ideally verify that the component renders correctly with remote names
|
||||
// But since we're mocking the sub-components, this is primarily a test of the
|
||||
// component's structure rather than its full rendering
|
||||
});
|
||||
});
|
||||
@@ -26,11 +26,13 @@ import ChannelHeaderMenu from '../channel_header_menu/channel_header_menu';
|
||||
type Props = {
|
||||
dmUser?: UserProfile;
|
||||
gmMembers?: UserProfile[];
|
||||
remoteNames?: string[];
|
||||
}
|
||||
|
||||
const ChannelHeaderTitle = ({
|
||||
dmUser,
|
||||
gmMembers,
|
||||
remoteNames,
|
||||
}: Props) => {
|
||||
const channel = useSelector(getCurrentChannel);
|
||||
|
||||
@@ -53,6 +55,7 @@ const ChannelHeaderTitle = ({
|
||||
<SharedChannelIndicator
|
||||
className='shared-channel-icon'
|
||||
withTooltip={true}
|
||||
remoteNames={remoteNames}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
updateChannelNotifyProps,
|
||||
} from 'mattermost-redux/actions/channels';
|
||||
import {getCustomEmojisInText} from 'mattermost-redux/actions/emojis';
|
||||
import {fetchChannelRemotes} from 'mattermost-redux/actions/shared_channels';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
import {
|
||||
getCurrentChannel,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
getCurrentChannelStats,
|
||||
} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getRemoteNamesForChannel} from 'mattermost-redux/selectors/entities/shared_channels';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {
|
||||
displayLastActiveLabel,
|
||||
@@ -60,6 +62,7 @@ function makeMapStateToProps() {
|
||||
let gmMembers;
|
||||
let customStatus;
|
||||
let lastActivityTimestamp;
|
||||
let remoteNames: string[] = [];
|
||||
|
||||
if (channel && channel.type === General.DM_CHANNEL) {
|
||||
const dmUserId = getUserIdFromChannelName(user.id, channel.name);
|
||||
@@ -69,6 +72,11 @@ function makeMapStateToProps() {
|
||||
} else if (channel && channel.type === General.GM_CHANNEL) {
|
||||
gmMembers = doGetProfilesInChannel(state, channel.id);
|
||||
}
|
||||
|
||||
if (channel?.shared) {
|
||||
remoteNames = getRemoteNamesForChannel(state, channel.id);
|
||||
}
|
||||
|
||||
const stats = getCurrentChannelStats(state);
|
||||
|
||||
let isLastActiveEnabled = false;
|
||||
@@ -85,6 +93,7 @@ function makeMapStateToProps() {
|
||||
currentUser: user,
|
||||
dmUser,
|
||||
gmMembers,
|
||||
remoteNames,
|
||||
rhsState: getRhsState(state),
|
||||
isChannelMuted: isCurrentChannelMuted(state),
|
||||
hasGuests: stats ? stats.guest_count > 0 : false,
|
||||
@@ -110,6 +119,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
getCustomEmojisInText,
|
||||
updateChannelNotifyProps,
|
||||
showChannelMembers,
|
||||
fetchChannelRemotes,
|
||||
}, dispatch),
|
||||
});
|
||||
|
||||
|
||||
181
webapp/channels/src/components/shared_channel_indicator.test.tsx
Обычный файл
181
webapp/channels/src/components/shared_channel_indicator.test.tsx
Обычный файл
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithContext, screen, waitFor, act} from 'tests/react_testing_utils';
|
||||
|
||||
import SharedChannelIndicator from './shared_channel_indicator';
|
||||
|
||||
describe('components/SharedChannelIndicator', () => {
|
||||
test('should render without tooltip', () => {
|
||||
renderWithContext(
|
||||
<SharedChannelIndicator withTooltip={false}/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('SharedChannelIcon')).toHaveClass('icon-circle-multiple-outline');
|
||||
});
|
||||
|
||||
test('should render with default tooltip when no remote names', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
renderWithContext(
|
||||
<SharedChannelIndicator withTooltip={true}/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedChannelIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Shared with trusted organizations')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should render with remote names in tooltip', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const remoteNames = ['Remote 1', 'Remote 2'];
|
||||
renderWithContext(
|
||||
<SharedChannelIndicator
|
||||
withTooltip={true}
|
||||
remoteNames={remoteNames}
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedChannelIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Shared with: Remote 1, Remote 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should truncate and show count when more than 3 remote names', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const remoteNames = ['Remote 1', 'Remote 2', 'Remote 3', 'Remote 4', 'Remote 5'];
|
||||
renderWithContext(
|
||||
<SharedChannelIndicator
|
||||
withTooltip={true}
|
||||
remoteNames={remoteNames}
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedChannelIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Shared with: Remote 1, Remote 2, Remote 3 and 2 others')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should truncate long organization names with ellipsis', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const remoteNames = ['A Very Very Very Very Very Long Organization Name That Needs Truncation', 'Remote 2'];
|
||||
renderWithContext(
|
||||
<SharedChannelIndicator
|
||||
withTooltip={true}
|
||||
remoteNames={remoteNames}
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedChannelIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Shared with: A Very Very Very Very Very Lon..., Remote 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should correctly handle singular "other" in text', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const remoteNames = ['Remote 1', 'Remote 2', 'Remote 3', 'Remote 4'];
|
||||
renderWithContext(
|
||||
<SharedChannelIndicator
|
||||
withTooltip={true}
|
||||
remoteNames={remoteNames}
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedChannelIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Shared with: Remote 1, Remote 2, Remote 3 and 1 other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should limit the overall tooltip length for extremely long content', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Generate an array of very long remote names that would produce an extremely long tooltip
|
||||
const longRemoteNames = [
|
||||
'Very Long Organization Name 1 That Exceeds Length Limits',
|
||||
'Very Long Organization Name 2 That Exceeds Length Limits',
|
||||
'Very Long Organization Name 3 That Exceeds Length Limits',
|
||||
'Very Long Organization Name 4 That Exceeds Length Limits',
|
||||
'Very Long Organization Name 5 That Exceeds Length Limits',
|
||||
'Very Long Organization Name 6 That Exceeds Length Limits',
|
||||
];
|
||||
|
||||
renderWithContext(
|
||||
<SharedChannelIndicator
|
||||
withTooltip={true}
|
||||
remoteNames={longRemoteNames}
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedChannelIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that the tooltip contains text with ellipsis, indicating truncation
|
||||
const tooltipText = screen.getByText(/Shared with:.*\.\.\./);
|
||||
expect(tooltipText).toBeInTheDocument();
|
||||
|
||||
// Verify overall tooltip length doesn't exceed the maximum length (120)
|
||||
// Add some extra characters to account for the "Shared with: " prefix
|
||||
const tooltipContent = tooltipText.textContent || '';
|
||||
const actualContent = tooltipContent.replace('Shared with: ', '');
|
||||
expect(actualContent.length).toBeLessThanOrEqual(120);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -9,21 +9,87 @@ import WithTooltip from 'components/with_tooltip';
|
||||
type Props = {
|
||||
className?: string;
|
||||
withTooltip?: boolean;
|
||||
remoteNames?: string[];
|
||||
};
|
||||
|
||||
const SharedChannelIndicator: React.FC<Props> = (props: Props): JSX.Element => {
|
||||
const sharedIcon = (<i className={`${props.className || ''} icon-circle-multiple-outline`}/>);
|
||||
const sharedIcon = (
|
||||
<i
|
||||
data-testid='SharedChannelIcon'
|
||||
className={`${props.className || ''} icon-circle-multiple-outline`}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!props.withTooltip) {
|
||||
return sharedIcon;
|
||||
}
|
||||
|
||||
const sharedTooltipText = (
|
||||
<FormattedMessage
|
||||
id='shared_channel_indicator.tooltip'
|
||||
defaultMessage='Shared with trusted organizations'
|
||||
/>
|
||||
);
|
||||
let sharedTooltipText;
|
||||
|
||||
if (props.remoteNames && props.remoteNames.length > 0) {
|
||||
// If we have remote names, display them in the tooltip
|
||||
// Show first 3 remotes and then "and N others" if there are more
|
||||
const MAX_DISPLAY_NAMES = 3;
|
||||
const MAX_NAME_LENGTH = 30;
|
||||
const MAX_TOOLTIP_LENGTH = 120; // Maximum overall tooltip length
|
||||
|
||||
// Truncate long organization names
|
||||
const truncatedNames = props.remoteNames.map((name) => (
|
||||
name.length > MAX_NAME_LENGTH ?
|
||||
`${name.substring(0, MAX_NAME_LENGTH)}...` :
|
||||
name
|
||||
));
|
||||
|
||||
if (truncatedNames.length <= MAX_DISPLAY_NAMES) {
|
||||
// If we have 3 or fewer organizations, just display them all separated by commas
|
||||
sharedTooltipText = (
|
||||
<FormattedMessage
|
||||
id='shared_channel_indicator.tooltip_with_names.few'
|
||||
defaultMessage='Shared with: {organizations}'
|
||||
values={{
|
||||
organizations: truncatedNames.join(', '),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// If we have more than MAX_DISPLAY_NAMES organizations, show the first few and then "and N others"
|
||||
const displayNames = truncatedNames.slice(0, MAX_DISPLAY_NAMES);
|
||||
const remainingCount = truncatedNames.length - MAX_DISPLAY_NAMES;
|
||||
|
||||
sharedTooltipText = (
|
||||
<FormattedMessage
|
||||
id='shared_channel_indicator.tooltip_with_names.many'
|
||||
defaultMessage='Shared with: {organizations} and {count, number} {count, plural, one {other} other {others}}'
|
||||
values={{
|
||||
organizations: displayNames.join(', '),
|
||||
count: remainingCount,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Add a final truncation to enforce maximum tooltip length
|
||||
if (truncatedNames.join(', ').length > MAX_TOOLTIP_LENGTH) {
|
||||
const truncatedStr = truncatedNames.join(', ').substring(0, MAX_TOOLTIP_LENGTH - 3) + '...';
|
||||
sharedTooltipText = (
|
||||
<FormattedMessage
|
||||
id='shared_channel_indicator.tooltip_with_names'
|
||||
defaultMessage='Shared with: {remoteNames}'
|
||||
values={{
|
||||
remoteNames: truncatedStr,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Fallback to generic message if no remote names are available
|
||||
sharedTooltipText = (
|
||||
<FormattedMessage
|
||||
id='shared_channel_indicator.tooltip'
|
||||
defaultMessage='Shared with trusted organizations'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WithTooltip
|
||||
|
||||
206
webapp/channels/src/components/shared_user_indicator.test.tsx
Обычный файл
206
webapp/channels/src/components/shared_user_indicator.test.tsx
Обычный файл
@@ -0,0 +1,206 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import * as reactIntl from 'react-intl';
|
||||
|
||||
import {renderWithContext, screen, waitFor, act} from 'tests/react_testing_utils';
|
||||
|
||||
import SharedUserIndicator from './shared_user_indicator';
|
||||
|
||||
describe('components/SharedUserIndicator', () => {
|
||||
// Mocks for i18n
|
||||
beforeEach(() => {
|
||||
const mockIntl = {
|
||||
formatMessage: jest.fn((descriptor) => {
|
||||
if (descriptor.id === 'shared_user_indicator.tooltip') {
|
||||
return 'From trusted organizations';
|
||||
}
|
||||
if (descriptor.id === 'shared_user_indicator.tooltip_with_names') {
|
||||
return `From: ${descriptor.values?.remoteNames}`;
|
||||
}
|
||||
if (descriptor.id === 'shared_user_indicator.aria_label') {
|
||||
return 'shared user indicator';
|
||||
}
|
||||
return descriptor.defaultMessage || '';
|
||||
}),
|
||||
locale: 'en',
|
||||
defaultLocale: 'en',
|
||||
messages: {},
|
||||
};
|
||||
|
||||
jest.spyOn(reactIntl, 'useIntl').mockImplementation(() => mockIntl as any);
|
||||
});
|
||||
|
||||
test('should render without tooltip', () => {
|
||||
renderWithContext(
|
||||
<SharedUserIndicator withTooltip={false}/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedUserIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
});
|
||||
|
||||
test('should render with custom title in tooltip', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
renderWithContext(
|
||||
<SharedUserIndicator
|
||||
withTooltip={true}
|
||||
title='Custom title'
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedUserIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Custom title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should render with default tooltip when no remote names', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
renderWithContext(
|
||||
<SharedUserIndicator withTooltip={true}/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedUserIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('From trusted organizations')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should render with remote names in tooltip', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const remoteNames = ['Remote 1', 'Remote 2'];
|
||||
renderWithContext(
|
||||
<SharedUserIndicator
|
||||
withTooltip={true}
|
||||
remoteNames={remoteNames}
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedUserIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('From: Remote 1, Remote 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should truncate and show count when more than 3 remote names', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const remoteNames = ['Remote 1', 'Remote 2', 'Remote 3', 'Remote 4', 'Remote 5'];
|
||||
renderWithContext(
|
||||
<SharedUserIndicator
|
||||
withTooltip={true}
|
||||
remoteNames={remoteNames}
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedUserIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('From: Remote 1, Remote 2, Remote 3 and 2 others')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should correctly handle singular "other" in text', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const remoteNames = ['Remote 1', 'Remote 2', 'Remote 3', 'Remote 4'];
|
||||
renderWithContext(
|
||||
<SharedUserIndicator
|
||||
withTooltip={true}
|
||||
remoteNames={remoteNames}
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedUserIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('From: Remote 1, Remote 2, Remote 3 and 1 other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should limit the overall tooltip length for extremely long content', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Generate an array of very long remote names that would produce an extremely long tooltip
|
||||
const longRemoteNames = [
|
||||
'Very Long Organization Name 1 That Exceeds Length Limits',
|
||||
'Very Long Organization Name 2 That Exceeds Length Limits',
|
||||
'Very Long Organization Name 3 That Exceeds Length Limits',
|
||||
'Very Long Organization Name 4 That Exceeds Length Limits',
|
||||
'Very Long Organization Name 5 That Exceeds Length Limits',
|
||||
'Very Long Organization Name 6 That Exceeds Length Limits',
|
||||
];
|
||||
|
||||
renderWithContext(
|
||||
<SharedUserIndicator
|
||||
withTooltip={true}
|
||||
remoteNames={longRemoteNames}
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId('SharedUserIcon');
|
||||
expect(icon).toHaveClass('icon-circle-multiple-outline');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.hover(icon);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
// Just check that the tooltip text ends with ellipsis, indicating truncation
|
||||
const tooltipText = screen.getByText(/From:.+\.\.\.$/);
|
||||
expect(tooltipText).toBeInTheDocument();
|
||||
|
||||
// Verify overall tooltip length doesn't exceed the maximum length (120)
|
||||
// Add some extra characters to account for the "From: " prefix
|
||||
const tooltipContent = tooltipText.textContent || '';
|
||||
const actualContent = tooltipContent.replace('From: ', '');
|
||||
expect(actualContent.length).toBeLessThanOrEqual(120);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@ type Props = {
|
||||
|
||||
className?: string;
|
||||
withTooltip?: boolean;
|
||||
|
||||
remoteNames?: string[];
|
||||
};
|
||||
|
||||
const SharedUserIndicator = (props: Props) => {
|
||||
@@ -26,6 +28,7 @@ const SharedUserIndicator = (props: Props) => {
|
||||
|
||||
const sharedIcon = (
|
||||
<i
|
||||
data-testid='SharedUserIcon'
|
||||
className={classNames('icon icon-circle-multiple-outline', props.className)}
|
||||
aria-label={props.ariaLabel || intl.formatMessage({id: 'shared_user_indicator.aria_label', defaultMessage: 'shared user indicator'})}
|
||||
role={props?.role}
|
||||
@@ -36,6 +39,39 @@ const SharedUserIndicator = (props: Props) => {
|
||||
return sharedIcon;
|
||||
}
|
||||
|
||||
// If we have remote names, use them in the tooltip
|
||||
if (props.remoteNames && props.remoteNames.length > 0) {
|
||||
// Show first 3 remotes and then "and N others" if there are more
|
||||
const MAX_DISPLAY_NAMES = 3;
|
||||
const MAX_TOOLTIP_LENGTH = 120; // Maximum overall tooltip length
|
||||
let remoteNamesText;
|
||||
|
||||
if (props.remoteNames.length <= MAX_DISPLAY_NAMES) {
|
||||
remoteNamesText = props.remoteNames.join(', ');
|
||||
} else {
|
||||
const displayNames = props.remoteNames.slice(0, MAX_DISPLAY_NAMES);
|
||||
const remainingCount = props.remoteNames.length - MAX_DISPLAY_NAMES;
|
||||
remoteNamesText = `${displayNames.join(', ')} and ${remainingCount} other${remainingCount > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
// Add a final truncation to enforce maximum tooltip length
|
||||
if (remoteNamesText.length > MAX_TOOLTIP_LENGTH) {
|
||||
remoteNamesText = remoteNamesText.substring(0, MAX_TOOLTIP_LENGTH - 3) + '...';
|
||||
}
|
||||
|
||||
return (
|
||||
<WithTooltip
|
||||
title={intl.formatMessage(
|
||||
{id: 'shared_user_indicator.tooltip_with_names', defaultMessage: 'From: {remoteNames}'},
|
||||
{remoteNames: remoteNamesText},
|
||||
)}
|
||||
>
|
||||
{sharedIcon}
|
||||
</WithTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to the generic message
|
||||
return (
|
||||
<WithTooltip
|
||||
title={props.title || intl.formatMessage({id: 'shared_user_indicator.tooltip', defaultMessage: 'From trusted organizations'})}
|
||||
|
||||
@@ -1,5 +1,92 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/sidebar/sidebar_channel/sidebar_channel_link should fetch shared channels data when channel is shared 1`] = `
|
||||
<Link
|
||||
aria-label="channel_label"
|
||||
className="SidebarLink"
|
||||
id="sidebarItem_"
|
||||
onClick={[Function]}
|
||||
tabIndex={0}
|
||||
to="http://a.fake.link"
|
||||
>
|
||||
<SidebarChannelIcon
|
||||
icon={null}
|
||||
isDeleted={false}
|
||||
/>
|
||||
<div
|
||||
className="SidebarChannelLinkLabel_wrapper"
|
||||
>
|
||||
<span
|
||||
className="SidebarChannelLinkLabel"
|
||||
>
|
||||
channel_label
|
||||
</span>
|
||||
<Pluggable
|
||||
channel={
|
||||
Object {
|
||||
"create_at": 0,
|
||||
"creator_id": "",
|
||||
"delete_at": 0,
|
||||
"display_name": "channel_display_name",
|
||||
"group_constrained": false,
|
||||
"header": "",
|
||||
"id": "channel_id",
|
||||
"last_post_at": 0,
|
||||
"last_root_post_at": 0,
|
||||
"name": "",
|
||||
"purpose": "",
|
||||
"scheme_id": "",
|
||||
"team_id": "",
|
||||
"type": "O",
|
||||
"update_at": 0,
|
||||
}
|
||||
}
|
||||
pluggableName="SidebarChannelLinkLabel"
|
||||
/>
|
||||
<SharedChannelIndicator
|
||||
className="icon"
|
||||
remoteNames={Array []}
|
||||
withTooltip={true}
|
||||
/>
|
||||
</div>
|
||||
<Connect(Component)
|
||||
id="channel_id"
|
||||
/>
|
||||
<ChannelMentionBadge
|
||||
hasUrgent={false}
|
||||
unreadMentions={0}
|
||||
/>
|
||||
<div
|
||||
className="SidebarMenu MenuWrapper"
|
||||
>
|
||||
<Connect(Component)
|
||||
channel={
|
||||
Object {
|
||||
"create_at": 0,
|
||||
"creator_id": "",
|
||||
"delete_at": 0,
|
||||
"display_name": "channel_display_name",
|
||||
"group_constrained": false,
|
||||
"header": "",
|
||||
"id": "channel_id",
|
||||
"last_post_at": 0,
|
||||
"last_root_post_at": 0,
|
||||
"name": "",
|
||||
"purpose": "",
|
||||
"scheme_id": "",
|
||||
"team_id": "",
|
||||
"type": "O",
|
||||
"update_at": 0,
|
||||
}
|
||||
}
|
||||
channelLink="http://a.fake.link"
|
||||
isUnread={false}
|
||||
onMenuToggle={[Function]}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
`;
|
||||
|
||||
exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match snapshot 1`] = `
|
||||
<Link
|
||||
aria-label="channel_label"
|
||||
@@ -331,3 +418,177 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn
|
||||
</div>
|
||||
</Link>
|
||||
`;
|
||||
|
||||
exports[`components/sidebar/sidebar_channel/sidebar_channel_link should not fetch shared channels data when data already exists 1`] = `
|
||||
<Link
|
||||
aria-label="channel_label"
|
||||
className="SidebarLink"
|
||||
id="sidebarItem_"
|
||||
onClick={[Function]}
|
||||
tabIndex={0}
|
||||
to="http://a.fake.link"
|
||||
>
|
||||
<SidebarChannelIcon
|
||||
icon={null}
|
||||
isDeleted={false}
|
||||
/>
|
||||
<div
|
||||
className="SidebarChannelLinkLabel_wrapper"
|
||||
>
|
||||
<span
|
||||
className="SidebarChannelLinkLabel"
|
||||
>
|
||||
channel_label
|
||||
</span>
|
||||
<Pluggable
|
||||
channel={
|
||||
Object {
|
||||
"create_at": 0,
|
||||
"creator_id": "",
|
||||
"delete_at": 0,
|
||||
"display_name": "channel_display_name",
|
||||
"group_constrained": false,
|
||||
"header": "",
|
||||
"id": "channel_id",
|
||||
"last_post_at": 0,
|
||||
"last_root_post_at": 0,
|
||||
"name": "",
|
||||
"purpose": "",
|
||||
"scheme_id": "",
|
||||
"team_id": "",
|
||||
"type": "O",
|
||||
"update_at": 0,
|
||||
}
|
||||
}
|
||||
pluggableName="SidebarChannelLinkLabel"
|
||||
/>
|
||||
<SharedChannelIndicator
|
||||
className="icon"
|
||||
remoteNames={
|
||||
Array [
|
||||
"Remote 1",
|
||||
"Remote 2",
|
||||
]
|
||||
}
|
||||
withTooltip={true}
|
||||
/>
|
||||
</div>
|
||||
<Connect(Component)
|
||||
id="channel_id"
|
||||
/>
|
||||
<ChannelMentionBadge
|
||||
hasUrgent={false}
|
||||
unreadMentions={0}
|
||||
/>
|
||||
<div
|
||||
className="SidebarMenu MenuWrapper"
|
||||
>
|
||||
<Connect(Component)
|
||||
channel={
|
||||
Object {
|
||||
"create_at": 0,
|
||||
"creator_id": "",
|
||||
"delete_at": 0,
|
||||
"display_name": "channel_display_name",
|
||||
"group_constrained": false,
|
||||
"header": "",
|
||||
"id": "channel_id",
|
||||
"last_post_at": 0,
|
||||
"last_root_post_at": 0,
|
||||
"name": "",
|
||||
"purpose": "",
|
||||
"scheme_id": "",
|
||||
"team_id": "",
|
||||
"type": "O",
|
||||
"update_at": 0,
|
||||
}
|
||||
}
|
||||
channelLink="http://a.fake.link"
|
||||
isUnread={false}
|
||||
onMenuToggle={[Function]}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
`;
|
||||
|
||||
exports[`components/sidebar/sidebar_channel/sidebar_channel_link should not fetch shared channels for non-shared channels 1`] = `
|
||||
<Link
|
||||
aria-label="channel_label"
|
||||
className="SidebarLink"
|
||||
id="sidebarItem_"
|
||||
onClick={[Function]}
|
||||
tabIndex={0}
|
||||
to="http://a.fake.link"
|
||||
>
|
||||
<SidebarChannelIcon
|
||||
icon={null}
|
||||
isDeleted={false}
|
||||
/>
|
||||
<div
|
||||
className="SidebarChannelLinkLabel_wrapper"
|
||||
>
|
||||
<span
|
||||
className="SidebarChannelLinkLabel"
|
||||
>
|
||||
channel_label
|
||||
</span>
|
||||
<Pluggable
|
||||
channel={
|
||||
Object {
|
||||
"create_at": 0,
|
||||
"creator_id": "",
|
||||
"delete_at": 0,
|
||||
"display_name": "channel_display_name",
|
||||
"group_constrained": false,
|
||||
"header": "",
|
||||
"id": "channel_id",
|
||||
"last_post_at": 0,
|
||||
"last_root_post_at": 0,
|
||||
"name": "",
|
||||
"purpose": "",
|
||||
"scheme_id": "",
|
||||
"team_id": "",
|
||||
"type": "O",
|
||||
"update_at": 0,
|
||||
}
|
||||
}
|
||||
pluggableName="SidebarChannelLinkLabel"
|
||||
/>
|
||||
</div>
|
||||
<Connect(Component)
|
||||
id="channel_id"
|
||||
/>
|
||||
<ChannelMentionBadge
|
||||
hasUrgent={false}
|
||||
unreadMentions={0}
|
||||
/>
|
||||
<div
|
||||
className="SidebarMenu MenuWrapper"
|
||||
>
|
||||
<Connect(Component)
|
||||
channel={
|
||||
Object {
|
||||
"create_at": 0,
|
||||
"creator_id": "",
|
||||
"delete_at": 0,
|
||||
"display_name": "channel_display_name",
|
||||
"group_constrained": false,
|
||||
"header": "",
|
||||
"id": "channel_id",
|
||||
"last_post_at": 0,
|
||||
"last_root_post_at": 0,
|
||||
"name": "",
|
||||
"purpose": "",
|
||||
"scheme_id": "",
|
||||
"team_id": "",
|
||||
"type": "O",
|
||||
"update_at": 0,
|
||||
}
|
||||
}
|
||||
channelLink="http://a.fake.link"
|
||||
isUnread={false}
|
||||
onMenuToggle={[Function]}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
`;
|
||||
|
||||
@@ -7,10 +7,12 @@ import type {Dispatch} from 'redux';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import {fetchChannelRemotes} from 'mattermost-redux/actions/shared_channels';
|
||||
import {makeGetChannelUnreadCount} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentUserId, getMyChannelMemberships} from 'mattermost-redux/selectors/entities/common';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getInt} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getRemoteNamesForChannel} from 'mattermost-redux/selectors/entities/shared_channels';
|
||||
import {isChannelMuted} from 'mattermost-redux/utils/channel_utils';
|
||||
|
||||
import {markMostRecentPostInChannelAsUnread, unsetEditingPost} from 'actions/post_actions';
|
||||
@@ -50,6 +52,10 @@ function makeMapStateToProps() {
|
||||
const isOnboardingFlowEnabled = config.EnableOnboardingFlow;
|
||||
const showChannelsTour = enableTutorial && tutorialStep === OnboardingTourSteps.CHANNELS_AND_DIRECT_MESSAGES;
|
||||
const showChannelsTutorialStep = showChannelsTour && channelTourTriggered && isOnboardingFlowEnabled === 'true';
|
||||
|
||||
const remoteNames = ownProps.channel?.shared ?
|
||||
getRemoteNamesForChannel(state, ownProps.channel.id) : [];
|
||||
|
||||
return {
|
||||
unreadMentions: unreadCount.mentions,
|
||||
unreadMsgs: unreadCount.messages,
|
||||
@@ -61,6 +67,7 @@ function makeMapStateToProps() {
|
||||
showChannelsTutorialStep,
|
||||
rhsState: getRhsState(state),
|
||||
rhsOpen: getIsRhsOpen(state),
|
||||
remoteNames,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -74,6 +81,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
|
||||
multiSelectChannelTo,
|
||||
multiSelectChannelAdd,
|
||||
closeRightHandSide,
|
||||
fetchChannelRemotes,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,10 +5,17 @@ import React from 'react';
|
||||
|
||||
import type {ChannelType} from '@mattermost/types/channels';
|
||||
|
||||
import type {SidebarChannelLink as SidebarChannelLinkComponent} from 'components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link';
|
||||
import SidebarChannelLink from 'components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link';
|
||||
import SidebarChannelLink, {type SidebarChannelLink as SidebarChannelLinkComponent} from 'components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link';
|
||||
|
||||
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import {shallowWithIntl, defaultIntl} from 'tests/helpers/intl-test-helper';
|
||||
|
||||
jest.mock('packages/mattermost-redux/src/selectors/entities/shared_channels', () => ({
|
||||
getRemoteNamesForChannel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('packages/mattermost-redux/src/actions/shared_channels', () => ({
|
||||
fetchChannelRemotes: jest.fn(() => ({type: 'MOCK_ACTION'})),
|
||||
}));
|
||||
|
||||
describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => {
|
||||
const baseProps = {
|
||||
@@ -38,6 +45,10 @@ describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => {
|
||||
isChannelSelected: false,
|
||||
hasUrgent: false,
|
||||
showChannelsTutorialStep: false,
|
||||
remoteNames: [],
|
||||
isSharedChannel: false,
|
||||
fetchChannelRemotes: jest.fn(),
|
||||
intl: defaultIntl,
|
||||
actions: {
|
||||
markMostRecentPostInChannelAsUnread: jest.fn(),
|
||||
multiSelectChannel: jest.fn(),
|
||||
@@ -47,6 +58,7 @@ describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => {
|
||||
openLhs: jest.fn(),
|
||||
unsetEditingPost: jest.fn(),
|
||||
closeRightHandSide: jest.fn(),
|
||||
fetchChannelRemotes: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -109,4 +121,72 @@ describe('components/sidebar/sidebar_channel/sidebar_channel_link', () => {
|
||||
instance.enableToolTipIfNeeded();
|
||||
expect(instance.state.showTooltip).toBe(true);
|
||||
});
|
||||
|
||||
test('should not fetch shared channels for non-shared channels', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isSharedChannel: false,
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(
|
||||
<SidebarChannelLink {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(props.actions.fetchChannelRemotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should fetch shared channels data when channel is shared', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isSharedChannel: true,
|
||||
remoteNames: [],
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(
|
||||
<SidebarChannelLink {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(props.actions.fetchChannelRemotes).toHaveBeenCalledWith('channel_id');
|
||||
});
|
||||
|
||||
test('should not fetch shared channels data when data already exists', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isSharedChannel: true,
|
||||
remoteNames: ['Remote 1', 'Remote 2'],
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(
|
||||
<SidebarChannelLink {...props}/>,
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(props.actions.fetchChannelRemotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should refetch when channel changes', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isSharedChannel: true,
|
||||
remoteNames: [],
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(
|
||||
<SidebarChannelLink {...props}/>,
|
||||
);
|
||||
|
||||
props.actions.fetchChannelRemotes.mockClear();
|
||||
|
||||
wrapper.setProps({
|
||||
...props,
|
||||
channel: {
|
||||
...props.channel,
|
||||
id: 'new_channel_id',
|
||||
},
|
||||
});
|
||||
|
||||
expect(props.actions.fetchChannelRemotes).toHaveBeenCalledWith('new_channel_id');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,7 @@ type Props = WrappedComponentProps & {
|
||||
rhsState?: RhsState;
|
||||
rhsOpen?: boolean;
|
||||
isSharedChannel?: boolean;
|
||||
remoteNames: string[];
|
||||
|
||||
actions: {
|
||||
markMostRecentPostInChannelAsUnread: (channelId: string) => void;
|
||||
@@ -71,6 +72,7 @@ type Props = WrappedComponentProps & {
|
||||
multiSelectChannelAdd: (channelId: string) => void;
|
||||
unsetEditingPost: () => void;
|
||||
closeRightHandSide: () => void;
|
||||
fetchChannelRemotes: (channelId: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -95,12 +97,23 @@ export class SidebarChannelLink extends React.PureComponent<Props, State> {
|
||||
|
||||
componentDidMount(): void {
|
||||
this.enableToolTipIfNeeded();
|
||||
|
||||
if (this.props.isSharedChannel && this.props.channel?.id && this.props.remoteNames.length === 0) {
|
||||
this.props.actions.fetchChannelRemotes(this.props.channel.id);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props): void {
|
||||
if (prevProps.label !== this.props.label) {
|
||||
this.enableToolTipIfNeeded();
|
||||
}
|
||||
|
||||
if (this.props.isSharedChannel &&
|
||||
(prevProps.channel?.id !== this.props.channel?.id || prevProps.channel?.team_id !== this.props.channel?.team_id) &&
|
||||
this.props.remoteNames.length === 0 &&
|
||||
this.props.channel?.id) {
|
||||
this.props.actions.fetchChannelRemotes(this.props.channel.id);
|
||||
}
|
||||
}
|
||||
|
||||
enableToolTipIfNeeded = (): void => {
|
||||
@@ -227,6 +240,7 @@ export class SidebarChannelLink extends React.PureComponent<Props, State> {
|
||||
<SharedChannelIndicator
|
||||
className='icon'
|
||||
withTooltip={true}
|
||||
remoteNames={this.props.remoteNames}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
|
||||
import type {ConnectedProps} from 'react-redux';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import type {Dispatch} from 'redux';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {UserProfile as UserProfileType} from '@mattermost/types/users';
|
||||
|
||||
import {fetchRemoteClusterInfo} from 'mattermost-redux/actions/shared_channels';
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getRemoteDisplayName} from 'mattermost-redux/selectors/entities/shared_channels';
|
||||
import {getUser, makeGetDisplayName} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
@@ -31,18 +35,33 @@ function makeMapStateToProps() {
|
||||
return (state: GlobalState, ownProps: OwnProps) => {
|
||||
const user = getUser(state, ownProps.userId);
|
||||
const theme = getTheme(state);
|
||||
let remoteNames: string[] = [];
|
||||
|
||||
if (user?.remote_id) {
|
||||
const remoteDisplayName = getRemoteDisplayName(state, user.remote_id);
|
||||
if (remoteDisplayName) {
|
||||
remoteNames = [remoteDisplayName];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displayName: getDisplayName(state, ownProps.userId, true),
|
||||
user,
|
||||
theme,
|
||||
isShared: Boolean(user && user.remote_id),
|
||||
remoteNames,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const connector = connect(makeMapStateToProps);
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
actions: bindActionCreators({
|
||||
fetchRemoteClusterInfo,
|
||||
}, dispatch),
|
||||
});
|
||||
|
||||
const connector = connect(makeMapStateToProps, mapDispatchToProps);
|
||||
|
||||
export type PropsFromRedux = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connect(makeMapStateToProps)(UserProfile);
|
||||
export default connector(UserProfile);
|
||||
|
||||
@@ -19,6 +19,10 @@ describe('components/UserProfile', () => {
|
||||
userId: 'user_id',
|
||||
theme: Preferences.THEMES.onyx,
|
||||
isShared: false,
|
||||
remoteNames: [],
|
||||
actions: {
|
||||
fetchRemoteClusterInfo: jest.fn(),
|
||||
},
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ReactNode} from 'react';
|
||||
import React from 'react';
|
||||
import React, {useEffect} from 'react';
|
||||
|
||||
import {isGuest} from 'mattermost-redux/utils/user_utils';
|
||||
|
||||
@@ -31,7 +31,15 @@ export default function UserProfile({
|
||||
userId,
|
||||
channelId,
|
||||
overwriteIcon,
|
||||
remoteNames,
|
||||
actions,
|
||||
}: Props) {
|
||||
// Fetch remote info when component mounts for remote users
|
||||
useEffect(() => {
|
||||
if (user?.remote_id && (!remoteNames || remoteNames.length === 0)) {
|
||||
actions.fetchRemoteClusterInfo(user.remote_id);
|
||||
}
|
||||
}, [user?.remote_id, remoteNames, actions]);
|
||||
let name: ReactNode;
|
||||
if (user && displayUsername) {
|
||||
name = `@${(user.username)}`;
|
||||
@@ -88,6 +96,7 @@ export default function UserProfile({
|
||||
<SharedUserIndicator
|
||||
className='shared-user-icon'
|
||||
withTooltip={true}
|
||||
remoteNames={remoteNames}
|
||||
/>
|
||||
}
|
||||
{(user && user.is_bot) && <BotTag/>}
|
||||
|
||||
@@ -5281,8 +5281,12 @@
|
||||
"setting_picture.title": "Team Icon",
|
||||
"setting_picture.uploading": "Uploading...",
|
||||
"shared_channel_indicator.tooltip": "Shared with trusted organizations",
|
||||
"shared_channel_indicator.tooltip_with_names": "Shared with: {remoteNames}",
|
||||
"shared_channel_indicator.tooltip_with_names.few": "Shared with: {organizations}",
|
||||
"shared_channel_indicator.tooltip_with_names.many": "Shared with: {organizations} and {count, number} {count, plural, one {other} other {others}}",
|
||||
"shared_user_indicator.aria_label": "shared user indicator",
|
||||
"shared_user_indicator.tooltip": "From a trusted organization",
|
||||
"shared_user_indicator.tooltip_with_names": "From: {remoteNames}",
|
||||
"shortcuts.browser.channel_next": "Forward in history:\tAlt|Right",
|
||||
"shortcuts.browser.channel_next.mac": "Forward in history:\t⌘|]",
|
||||
"shortcuts.browser.channel_prev": "Back in history:\tAlt|Left",
|
||||
|
||||
@@ -28,6 +28,7 @@ import RoleTypes from './roles';
|
||||
import SchemeTypes from './schemes';
|
||||
import ScheduledPostTypes from './scheudled_posts';
|
||||
import SearchTypes from './search';
|
||||
import SharedChannelTypes from './shared_channels';
|
||||
import TeamTypes from './teams';
|
||||
import ThreadTypes from './threads';
|
||||
import UserTypes from './users';
|
||||
@@ -61,6 +62,7 @@ export {
|
||||
PlaybookType,
|
||||
ChannelBookmarkTypes,
|
||||
ScheduledPostTypes,
|
||||
SharedChannelTypes,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import keyMirror from 'mattermost-redux/utils/key_mirror';
|
||||
|
||||
export default keyMirror({
|
||||
RECEIVED_CHANNEL_REMOTES: null,
|
||||
RECEIVED_REMOTE_CLUSTER_INFO: null,
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {RemoteClusterInfo} from '@mattermost/types/shared_channels';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import SharedChannelTypes from '../../action_types/shared_channels';
|
||||
import {fetchChannelRemotes, receivedChannelRemotes} from '../shared_channels';
|
||||
|
||||
jest.mock('mattermost-redux/client');
|
||||
|
||||
describe('shared_channels actions', () => {
|
||||
test('receivedChannelRemotes should create the correct action', () => {
|
||||
const channelId = 'channel1';
|
||||
const remotes: RemoteClusterInfo[] = [
|
||||
{
|
||||
name: 'remote1',
|
||||
display_name: 'Remote 1',
|
||||
create_at: 123,
|
||||
last_ping_at: 456,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
name: 'remote2',
|
||||
display_name: 'Remote 2',
|
||||
create_at: 789,
|
||||
last_ping_at: 101,
|
||||
delete_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const action = receivedChannelRemotes(channelId, remotes);
|
||||
|
||||
expect(action).toEqual({
|
||||
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
|
||||
data: {
|
||||
channelId,
|
||||
remotes,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('fetchChannelRemotes should fetch and dispatch remotes', async () => {
|
||||
const channelId = 'channel1';
|
||||
const remotes: RemoteClusterInfo[] = [
|
||||
{
|
||||
name: 'remote1',
|
||||
display_name: 'Remote 1',
|
||||
create_at: 123,
|
||||
last_ping_at: 456,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
name: 'remote2',
|
||||
display_name: 'Remote 2',
|
||||
create_at: 789,
|
||||
last_ping_at: 101,
|
||||
delete_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the client response
|
||||
(Client4.getSharedChannelRemoteInfos as jest.Mock).mockResolvedValueOnce(remotes);
|
||||
|
||||
// Mock the getState function to return no existing remotes
|
||||
const getState = jest.fn().mockReturnValue({
|
||||
entities: {
|
||||
sharedChannels: {
|
||||
remotes: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
|
||||
await fetchChannelRemotes(channelId)(dispatch, getState, {});
|
||||
|
||||
// Verify Client4 was called
|
||||
expect(Client4.getSharedChannelRemoteInfos).toHaveBeenCalledWith(channelId);
|
||||
|
||||
// Verify the action was dispatched
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
|
||||
data: {
|
||||
channelId,
|
||||
remotes,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('fetchChannelRemotes should not fetch if remotes already exist and no refresh is requested', async () => {
|
||||
const channelId = 'channel1';
|
||||
const remotes: RemoteClusterInfo[] = [
|
||||
{
|
||||
name: 'remote1',
|
||||
display_name: 'Remote 1',
|
||||
create_at: 123,
|
||||
last_ping_at: 456,
|
||||
delete_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the getState function to return existing remotes
|
||||
const getState = jest.fn().mockReturnValue({
|
||||
entities: {
|
||||
sharedChannels: {
|
||||
remotes: {
|
||||
[channelId]: remotes,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
|
||||
await fetchChannelRemotes(channelId)(dispatch, getState, {});
|
||||
|
||||
// Verify Client4 was NOT called
|
||||
expect(Client4.getSharedChannelRemoteInfos).not.toHaveBeenCalled();
|
||||
|
||||
// Verify no action was dispatched
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('fetchChannelRemotes should fetch if remotes exist but forceRefresh is true', async () => {
|
||||
const channelId = 'channel1';
|
||||
const existingRemotes: RemoteClusterInfo[] = [
|
||||
{
|
||||
name: 'remote1',
|
||||
display_name: 'Remote 1',
|
||||
create_at: 123,
|
||||
last_ping_at: 456,
|
||||
delete_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const newRemotes: RemoteClusterInfo[] = [
|
||||
{
|
||||
name: 'remote1',
|
||||
display_name: 'Remote 1',
|
||||
create_at: 123,
|
||||
last_ping_at: 456,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
name: 'remote2',
|
||||
display_name: 'Remote 2',
|
||||
create_at: 789,
|
||||
last_ping_at: 101,
|
||||
delete_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the getState function to return existing remotes
|
||||
const getState = jest.fn().mockReturnValue({
|
||||
entities: {
|
||||
sharedChannels: {
|
||||
remotes: {
|
||||
[channelId]: existingRemotes,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
|
||||
// Mock the client response to return updated remotes
|
||||
(Client4.getSharedChannelRemoteInfos as jest.Mock).mockResolvedValueOnce(newRemotes);
|
||||
|
||||
await fetchChannelRemotes(channelId, true)(dispatch, getState, {});
|
||||
|
||||
// Verify Client4 was called
|
||||
expect(Client4.getSharedChannelRemoteInfos).toHaveBeenCalledWith(channelId);
|
||||
|
||||
// Verify the action was dispatched with the new remotes
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
|
||||
data: {
|
||||
channelId,
|
||||
remotes: newRemotes,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {RemoteClusterInfo} from '@mattermost/types/shared_channels';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
import {logError} from 'mattermost-redux/actions/errors';
|
||||
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
|
||||
|
||||
import SharedChannelTypes from '../action_types/shared_channels';
|
||||
|
||||
export function receivedChannelRemotes(channelId: string, remotes: RemoteClusterInfo[]) {
|
||||
return {
|
||||
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
|
||||
data: {
|
||||
channelId,
|
||||
remotes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function receivedRemoteClusterInfo(remoteId: string, remoteInfo: RemoteClusterInfo) {
|
||||
return {
|
||||
type: SharedChannelTypes.RECEIVED_REMOTE_CLUSTER_INFO,
|
||||
data: {
|
||||
remoteId,
|
||||
remoteInfo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchChannelRemotes(channelId: string, forceRefresh = false): ActionFuncAsync<RemoteClusterInfo[]> {
|
||||
return async (dispatch: any, getState: () => GlobalState) => {
|
||||
// Check if we already have the data in the Redux store
|
||||
const state = getState();
|
||||
const remotes = state.entities?.sharedChannels?.remotes?.[channelId];
|
||||
|
||||
// If we already have the data and no refresh is requested, use the cached data
|
||||
if (!forceRefresh && remotes && remotes.length > 0) {
|
||||
return {data: remotes};
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await Client4.getSharedChannelRemoteInfos(channelId);
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(error, dispatch, getState);
|
||||
dispatch(logError(error));
|
||||
return {error};
|
||||
}
|
||||
|
||||
if (data) {
|
||||
dispatch(receivedChannelRemotes(channelId, data));
|
||||
}
|
||||
|
||||
return {data};
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchRemoteClusterInfo(remoteId: string, forceRefresh = false): ActionFuncAsync<RemoteClusterInfo> {
|
||||
return async (dispatch: any, getState: () => GlobalState) => {
|
||||
// Check if we already have the remote info cached
|
||||
const state = getState();
|
||||
const cachedRemote = state.entities?.sharedChannels?.remotesByRemoteId?.[remoteId];
|
||||
|
||||
if (!forceRefresh && cachedRemote) {
|
||||
return {data: cachedRemote};
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await Client4.getRemoteClusterInfo(remoteId);
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(error, dispatch, getState);
|
||||
dispatch(logError(error));
|
||||
return {error};
|
||||
}
|
||||
|
||||
if (data) {
|
||||
dispatch(receivedRemoteClusterInfo(remoteId, data));
|
||||
}
|
||||
|
||||
return {data};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import SharedChannelTypes from '../../../action_types/shared_channels';
|
||||
import {remotes} from '../shared_channels';
|
||||
|
||||
describe('shared_channels reducer', () => {
|
||||
test('RECEIVED_CHANNEL_REMOTES should store remotes correctly', () => {
|
||||
const channelId = 'channel1';
|
||||
const remotesList = [
|
||||
{
|
||||
name: 'remote1',
|
||||
display_name: 'Remote 1',
|
||||
create_at: 123,
|
||||
last_ping_at: 456,
|
||||
delete_at: 0,
|
||||
remote_id: 'r1',
|
||||
remote_team_id: 'rt1',
|
||||
site_url: 'http://remote1.com',
|
||||
creator_id: 'user1',
|
||||
plugin_id: 'plugin1',
|
||||
topics: 'topics1',
|
||||
options: 1,
|
||||
default_team_id: 'team1',
|
||||
},
|
||||
{
|
||||
name: 'remote2',
|
||||
display_name: 'Remote 2',
|
||||
create_at: 789,
|
||||
last_ping_at: 101,
|
||||
delete_at: 0,
|
||||
remote_id: 'r2',
|
||||
remote_team_id: 'rt2',
|
||||
site_url: 'http://remote2.com',
|
||||
creator_id: 'user2',
|
||||
plugin_id: 'plugin2',
|
||||
topics: 'topics2',
|
||||
options: 1,
|
||||
default_team_id: 'team2',
|
||||
},
|
||||
];
|
||||
|
||||
const action = {
|
||||
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
|
||||
data: {
|
||||
channelId,
|
||||
remotes: remotesList,
|
||||
},
|
||||
};
|
||||
|
||||
// Start with empty state
|
||||
let state = {};
|
||||
state = remotes(state, action);
|
||||
|
||||
// Verify the state was updated correctly
|
||||
expect(state).toEqual({
|
||||
[channelId]: remotesList,
|
||||
});
|
||||
|
||||
// Add remotes for another channel
|
||||
const channelId2 = 'channel2';
|
||||
const remotesList2 = [
|
||||
{
|
||||
name: 'remote3',
|
||||
display_name: 'Remote 3',
|
||||
create_at: 555,
|
||||
last_ping_at: 666,
|
||||
delete_at: 0,
|
||||
remote_id: 'r3',
|
||||
remote_team_id: 'rt3',
|
||||
site_url: 'http://remote3.com',
|
||||
creator_id: 'user3',
|
||||
plugin_id: 'plugin3',
|
||||
topics: 'topics3',
|
||||
options: 1,
|
||||
default_team_id: 'team3',
|
||||
},
|
||||
];
|
||||
|
||||
const action2 = {
|
||||
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
|
||||
data: {
|
||||
channelId: channelId2,
|
||||
remotes: remotesList2,
|
||||
},
|
||||
};
|
||||
|
||||
state = remotes(state, action2);
|
||||
|
||||
// Verify the state has both channel's remotes
|
||||
expect(state).toEqual({
|
||||
[channelId]: remotesList,
|
||||
[channelId2]: remotesList2,
|
||||
});
|
||||
|
||||
// Update the remotes for the first channel
|
||||
const updatedRemotesList = [
|
||||
{
|
||||
name: 'remote1',
|
||||
display_name: 'Remote 1 Updated',
|
||||
create_at: 123,
|
||||
last_ping_at: 789,
|
||||
delete_at: 0,
|
||||
remote_id: 'r1',
|
||||
remote_team_id: 'rt1',
|
||||
site_url: 'http://remote1.com',
|
||||
creator_id: 'user1',
|
||||
plugin_id: 'plugin1',
|
||||
topics: 'topics1',
|
||||
options: 1,
|
||||
default_team_id: 'team1',
|
||||
},
|
||||
];
|
||||
|
||||
const action3 = {
|
||||
type: SharedChannelTypes.RECEIVED_CHANNEL_REMOTES,
|
||||
data: {
|
||||
channelId,
|
||||
remotes: updatedRemotesList,
|
||||
},
|
||||
};
|
||||
|
||||
state = remotes(state, action3);
|
||||
|
||||
// Verify the first channel's remotes were updated and the second channel's remotes remain unchanged
|
||||
expect(state).toEqual({
|
||||
[channelId]: updatedRemotesList,
|
||||
[channelId2]: remotesList2,
|
||||
});
|
||||
});
|
||||
|
||||
test('Unknown action type should not modify state', () => {
|
||||
const state = {
|
||||
channel1: [
|
||||
{
|
||||
name: 'remote1',
|
||||
display_name: 'Remote 1',
|
||||
create_at: 123,
|
||||
last_ping_at: 456,
|
||||
delete_at: 0,
|
||||
remote_id: 'r1',
|
||||
remote_team_id: 'rt1',
|
||||
site_url: 'http://remote1.com',
|
||||
creator_id: 'user1',
|
||||
plugin_id: 'plugin1',
|
||||
topics: 'topics1',
|
||||
options: 1,
|
||||
default_team_id: 'team1',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'UNKNOWN_ACTION',
|
||||
data: {
|
||||
channelId: 'channel1',
|
||||
remotes: [
|
||||
{
|
||||
name: 'remote2',
|
||||
display_name: 'Remote 2',
|
||||
create_at: 789,
|
||||
last_ping_at: 101,
|
||||
delete_at: 0,
|
||||
remote_id: 'r2',
|
||||
remote_team_id: 'rt2',
|
||||
site_url: 'http://remote2.com',
|
||||
creator_id: 'user2',
|
||||
plugin_id: 'plugin2',
|
||||
topics: 'topics2',
|
||||
options: 1,
|
||||
default_team_id: 'team2',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const newState = remotes(state, action);
|
||||
|
||||
// State should be unchanged
|
||||
expect(newState).toBe(state);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import roles from './roles';
|
||||
import scheduledPosts from './scheduled_posts';
|
||||
import schemes from './schemes';
|
||||
import search from './search';
|
||||
import sharedChannels from './shared_channels';
|
||||
import teams from './teams';
|
||||
import threads from './threads';
|
||||
import typing from './typing';
|
||||
@@ -57,4 +58,5 @@ export default combineReducers({
|
||||
hostedCustomer,
|
||||
channelBookmarks,
|
||||
scheduledPosts,
|
||||
sharedChannels,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {combineReducers} from 'redux';
|
||||
import type {AnyAction} from 'redux';
|
||||
|
||||
import type {RemoteClusterInfo} from '@mattermost/types/shared_channels';
|
||||
|
||||
import SharedChannelTypes from '../../action_types/shared_channels';
|
||||
|
||||
export function remotes(state: Record<string, RemoteClusterInfo[]> = {}, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case SharedChannelTypes.RECEIVED_CHANNEL_REMOTES: {
|
||||
const {channelId, remotes} = action.data;
|
||||
return {
|
||||
...state,
|
||||
[channelId]: remotes,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function remotesByRemoteId(state: Record<string, RemoteClusterInfo> = {}, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case SharedChannelTypes.RECEIVED_REMOTE_CLUSTER_INFO: {
|
||||
const {remoteId, remoteInfo} = action.data;
|
||||
return {
|
||||
...state,
|
||||
[remoteId]: remoteInfo,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
remotes,
|
||||
remotesByRemoteId,
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {getRemoteNamesForChannel, getRemotesForChannel} from '../shared_channels';
|
||||
|
||||
describe('shared_channels selectors', () => {
|
||||
const channelId = 'channel1';
|
||||
const remotes = [
|
||||
{
|
||||
name: 'remote1',
|
||||
display_name: 'Remote 1',
|
||||
create_at: 123,
|
||||
last_ping_at: 456,
|
||||
delete_at: 0,
|
||||
},
|
||||
{
|
||||
name: 'remote2',
|
||||
display_name: 'Remote 2',
|
||||
create_at: 789,
|
||||
last_ping_at: 101,
|
||||
delete_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
sharedChannels: {
|
||||
remotes: {
|
||||
[channelId]: remotes,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('getRemoteNamesForChannel should return display names from remotes', () => {
|
||||
const result = getRemoteNamesForChannel(state as any, channelId);
|
||||
expect(result).toEqual(['Remote 1', 'Remote 2']);
|
||||
});
|
||||
|
||||
test('getRemoteNamesForChannel should return empty array when no remotes exist', () => {
|
||||
const result = getRemoteNamesForChannel(state as any, 'nonexistent_channel');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('getRemotesForChannel should return all remote info', () => {
|
||||
const result = getRemotesForChannel(state as any, channelId);
|
||||
expect(result).toEqual(remotes);
|
||||
});
|
||||
|
||||
test('getRemotesForChannel should return empty array when no remotes exist', () => {
|
||||
const result = getRemotesForChannel(state as any, 'nonexistent_channel');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {RemoteClusterInfo} from '@mattermost/types/shared_channels';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
export function getRemoteNamesForChannel(state: GlobalState, channelId: string): string[] {
|
||||
const remotes = state.entities?.sharedChannels?.remotes?.[channelId];
|
||||
if (remotes && remotes.length > 0) {
|
||||
return remotes.map((remote: RemoteClusterInfo) => remote.display_name);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getRemotesForChannel(state: GlobalState, channelId: string): RemoteClusterInfo[] {
|
||||
return state.entities?.sharedChannels?.remotes?.[channelId] || [];
|
||||
}
|
||||
|
||||
export function getRemoteClusterInfo(state: GlobalState, remoteId: string): RemoteClusterInfo | null {
|
||||
return state.entities?.sharedChannels?.remotesByRemoteId?.[remoteId] || null;
|
||||
}
|
||||
|
||||
export function getRemoteDisplayName(state: GlobalState, remoteId: string): string | null {
|
||||
const remote = getRemoteClusterInfo(state, remoteId);
|
||||
return remote?.display_name || null;
|
||||
}
|
||||
@@ -120,7 +120,7 @@ import type {ScheduledPost} from '@mattermost/types/schedule_post';
|
||||
import type {Scheme} from '@mattermost/types/schemes';
|
||||
import type {Session} from '@mattermost/types/sessions';
|
||||
import type {CompleteOnboardingRequest} from '@mattermost/types/setup';
|
||||
import type {SharedChannelRemote} from '@mattermost/types/shared_channels';
|
||||
import type {RemoteClusterInfo, SharedChannelRemote} from '@mattermost/types/shared_channels';
|
||||
import type {
|
||||
GetTeamMembersOpts,
|
||||
Team,
|
||||
@@ -2093,6 +2093,20 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
getSharedChannelRemoteInfos = (channelId: string) => {
|
||||
return this.doFetch<RemoteClusterInfo[]>(
|
||||
`${this.getBaseRoute()}/sharedchannels/${channelId}/remotes`,
|
||||
{method: 'GET'},
|
||||
);
|
||||
};
|
||||
|
||||
getRemoteClusterInfo = (remoteId: string) => {
|
||||
return this.doFetch<RemoteClusterInfo>(
|
||||
`${this.getBaseRoute()}/sharedchannels/remote_info/${remoteId}`,
|
||||
{method: 'GET'},
|
||||
);
|
||||
};
|
||||
|
||||
sharedChannelRemoteInvite = (remoteId: string, channelId: string) => {
|
||||
return this.doFetch<StatusOK>(
|
||||
`${this.getRemoteClusterRoute(remoteId)}/channels/${channelId}/invite`,
|
||||
|
||||
@@ -16,3 +16,17 @@ export type SharedChannelRemote = {
|
||||
last_post_create_at: number;
|
||||
last_post_create_id: string;
|
||||
}
|
||||
|
||||
// RemoteClusterInfo matches the server-side struct with subset of RemoteCluster fields
|
||||
export type RemoteClusterInfo = {
|
||||
name: string;
|
||||
display_name: string;
|
||||
create_at: number;
|
||||
delete_at: number;
|
||||
last_ping_at: number;
|
||||
};
|
||||
|
||||
export type SharedChannelsState = {
|
||||
remotes: Record<string, RemoteClusterInfo[]>;
|
||||
remotesByRemoteId: Record<string, RemoteClusterInfo>;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {Role} from './roles';
|
||||
import type {ScheduledPostsState} from './schedule_post';
|
||||
import type {SchemesState} from './schemes';
|
||||
import type {SearchState} from './search';
|
||||
import type {RemoteClusterInfo} from './shared_channels';
|
||||
import type {TeamsState} from './teams';
|
||||
import type {ThreadsState} from './threads';
|
||||
import type {Typing} from './typing';
|
||||
@@ -77,6 +78,10 @@ export type GlobalState = {
|
||||
hostedCustomer: HostedCustomerState;
|
||||
usage: CloudUsage;
|
||||
scheduledPosts: ScheduledPostsState;
|
||||
sharedChannels?: {
|
||||
remotes?: Record<string, RemoteClusterInfo[]>;
|
||||
remotesByRemoteId?: Record<string, RemoteClusterInfo>;
|
||||
};
|
||||
};
|
||||
errors: any[];
|
||||
requests: {
|
||||
|
||||
Ссылка в новой задаче
Block a user