mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-18 18:18:23 -05:00
Feature: Wrangler (#23602)
* Migrate feature/wrangler to mono-repo * Add wrangler files * Fix linters, types, etc * Fix snapshots * Fix playwright * Fix pipelines * Fix more pipeline * Fixes for pipelines * More changes for pipeline * Fix types * Add support for a feature flag, but leave it defaulted on for spinwick usage for now * Update snapshot * fix js error when removing last value of multiselect, support CSV marshaling to string array for textsetting * Fix linter * Remove TODO * Remove another TODO * fix tests * Fix i18n * Add server tests * Fix linter * Fix linter * Use proper icon for dot menu * Update snapshot * Add Cypress UI tests for various entrypoints to move thread modal, split SCSS out from forward post into its own thing * clean up * fix linter * More cleanup * Revert files to master * Fix linter for e2e tests * Make ForwardPostChannelSelect channel types configurable with a prop * Add missing return * Fixes from PR feedback * First batch of PR Feedback * Another batch of PR changes * Fix linter * Update snapshots * Wrangler system messages are translated to each user's locale * Initially translate Wrangler into system locale rather than initiating user * More fixes for PR Feedback * Fix some server tests * More updates with master. Fixes around pipelines. Enforce Enterprise license on front/back end * Add tests for dot_menu * More pipeline fixes * Fix e2etests prettier * Update cypress tests, change occurrences of 'Wrangler' with 'Move Thread' * Fix linter * Remove enterprise lock * A couple more occurrences of wrangler strings, and one more enterprise lock * Fix server tests * Fix i18n * Fix e2e linter * Feature flag shouldn't be on by default * Enable move threads feature in smoke tests (#25657) * enable move threads feature * add @prod tag * Fix move_thread_from_public_channel e2e test * Fix e2e style --------- Co-authored-by: Mattermost Build <build@mattermost.com> Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
This commit is contained in:
parent
bfb8320afd
commit
f0a336ba07
56 changed files with 3107 additions and 16 deletions
|
|
@ -1046,3 +1046,58 @@
|
|||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"/api/v4/posts/{post_id}/move":
|
||||
post:
|
||||
tags:
|
||||
- posts
|
||||
summary: Move a post (and any posts within that post's thread)
|
||||
description: >
|
||||
Move a post/thread to another channel.
|
||||
|
||||
THIS IS A BETA FEATURE. The API is subject to change without notice.
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must have `read_channel` permission for the channel the post is in.
|
||||
Must have `write_post` permission for the channel the post is being moved to.
|
||||
|
||||
|
||||
__Minimum server version__: 9.3
|
||||
operationId: MoveThread
|
||||
parameters:
|
||||
- name: post_id
|
||||
in: path
|
||||
description: The identifier of the post to move
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- channel_id
|
||||
properties:
|
||||
channel_id:
|
||||
type: string
|
||||
description: The channel identifier of where the post/thread is to be moved
|
||||
description: The channel identifier of where the post/thread is to be moved
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: Post moved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatusOK"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplemented"
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ services:
|
|||
MM_SERVICESETTINGS_ENABLEONBOARDINGFLOW: "false"
|
||||
MM_FEATUREFLAGS_ONBOARDINGTOURTIPS: "false"
|
||||
MM_SERVICEENVIRONMENT: "test"
|
||||
MM_FEATUREFLAGS_MOVETHREADSENABLED: "true"
|
||||
network_mode: host
|
||||
depends_on:
|
||||
$(for service in $ENABLED_DOCKER_SERVICES; do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @enterprise @messaging
|
||||
|
||||
import * as TIMEOUTS from '../../../fixtures/timeouts';
|
||||
|
||||
describe('Move Thread', () => {
|
||||
let user1;
|
||||
let user2;
|
||||
let testTeam;
|
||||
let dmChannel;
|
||||
let testPost;
|
||||
let replyPost;
|
||||
|
||||
const message = 'Move this message';
|
||||
const replyMessage = 'Move this reply';
|
||||
|
||||
beforeEach(() => {
|
||||
cy.shouldHaveFeatureFlag('MoveThreadsEnabled', true);
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_on',
|
||||
},
|
||||
WranglerSettings: {
|
||||
MoveThreadFromDirectMessageChannelEnable: true,
|
||||
},
|
||||
});
|
||||
|
||||
// # Login as new user, create new team and visit its URL
|
||||
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({
|
||||
user,
|
||||
team,
|
||||
}) => {
|
||||
user1 = user;
|
||||
testTeam = team;
|
||||
|
||||
// # enable CRT for the user
|
||||
cy.apiSaveCRTPreference(user.id, 'on');
|
||||
|
||||
// # Create another user
|
||||
return cy.apiCreateUser({prefix: 'second_'});
|
||||
}).then(({user}) => {
|
||||
user2 = user;
|
||||
|
||||
// # Add other user to team
|
||||
return cy.apiAddUserToTeam(testTeam.id, user2.id);
|
||||
}).then(() => {
|
||||
// # Create new DM channel
|
||||
return cy.apiCreateDirectChannel([user1.id, user2.id]);
|
||||
}).then(({channel}) => {
|
||||
dmChannel = channel;
|
||||
|
||||
// # Post a sample message
|
||||
return cy.postMessageAs({sender: user1, message, channelId: dmChannel.id});
|
||||
}).then((post) => {
|
||||
testPost = post.data;
|
||||
|
||||
// # Post a reply
|
||||
return cy.postMessageAs({sender: user1, message: replyMessage, channelId: dmChannel.id, rootId: testPost.id});
|
||||
}).then((post) => {
|
||||
replyPost = post.data;
|
||||
|
||||
// # Got to Test channel
|
||||
cy.visit(`/${testTeam.name}/channels/${dmChannel.name}`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// # Go to 1. public channel
|
||||
cy.visit(`/${testTeam.name}/channels/${dmChannel.name}`);
|
||||
});
|
||||
|
||||
it('Move root post from DM', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
||||
// # Click on ... button of last post
|
||||
cy.clickPostDotMenu(testPost.id);
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').type('{shift}W');
|
||||
|
||||
// # move thread
|
||||
movePostFromDM();
|
||||
|
||||
// * Assert switch to DM channel
|
||||
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', dmChannel.display_name);
|
||||
|
||||
// * Assert post has been moved
|
||||
verifyMovedThread({post: testPost});
|
||||
});
|
||||
|
||||
it('Move thread reply from DM', () => {
|
||||
// # Open the RHS with replies to the root post
|
||||
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
|
||||
|
||||
// * Assert RHS is open
|
||||
cy.get('#rhsContainer').should('be.visible');
|
||||
|
||||
// # Click on ... button of reply post
|
||||
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').type('{shift}W');
|
||||
|
||||
// # move thread
|
||||
movePostFromDM();
|
||||
|
||||
// * Assert switch to DM channel
|
||||
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', dmChannel.display_name);
|
||||
|
||||
// * Assert post has been moved
|
||||
verifyMovedThread({post: testPost});
|
||||
});
|
||||
|
||||
it('Move post from DM - Cancel using escape key', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
||||
// # Click on ... button of last post
|
||||
cy.clickPostDotMenu(testPost.id);
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').type('{shift}W');
|
||||
|
||||
// # move thread
|
||||
movePostFromDM({cancel: true});
|
||||
|
||||
// * Assert still in the DM channel
|
||||
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', dmChannel.display_name);
|
||||
|
||||
// * Assert last post id is identical with testPost
|
||||
cy.getLastPostId((id) => {
|
||||
assert.isEqual(id, testPost.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('Should not be able to move post from DM if configured off', () => {
|
||||
cy.apiUpdateConfig({
|
||||
WranglerSettings: {
|
||||
MoveThreadFromDirectMessageChannelEnable: false,
|
||||
},
|
||||
});
|
||||
|
||||
// # Open the RHS with replies to the root post
|
||||
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
|
||||
|
||||
// * Assert RHS is open
|
||||
cy.get('#rhsContainer').should('be.visible');
|
||||
|
||||
// # Click on ... button of reply post
|
||||
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').should('not.exist');
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that the post has been moved
|
||||
*
|
||||
* @param {Post} post
|
||||
*/
|
||||
const verifyMovedThread = ({post}) => {
|
||||
// * Assert post has been moved
|
||||
cy.getLastPostId().then((id) => {
|
||||
// * Assert last post is visible
|
||||
cy.get(`#${id}_message`).should('be.visible').within(() => {
|
||||
// * Assert the text in the preview matches the original post message
|
||||
cy.get(`#postMessageText_${post.id}`).should('be.visible').should('contain.text', post.message);
|
||||
});
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(id);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* move thread
|
||||
*
|
||||
* @param {object?} options
|
||||
* @param {boolean?} options.cancel
|
||||
*/
|
||||
const movePostFromDM = ({cancel = false} = {}) => {
|
||||
// * Assert visibility of the move thread modal
|
||||
cy.get('#move-thread-modal').should('be.visible').within(() => {
|
||||
// * Assert channel select is not existent
|
||||
cy.get('.move-thread__select').should('not.exist');
|
||||
|
||||
// * Assert if button is enabled
|
||||
cy.get('.GenericModal__button.confirm').should('not.be.disabled');
|
||||
|
||||
// * Assert Notification is shown
|
||||
cy.findByTestId('notification-text').should('be.visible').should('contain.text', 'Moving this thread changes who has access');
|
||||
|
||||
if (cancel) {
|
||||
// * Assert if button is active
|
||||
cy.get('.MoveThreadModal__cancel-button').should('not.be.disabled').type('{esc}', {force: true});
|
||||
} else {
|
||||
// * Assert if button is active
|
||||
cy.get('.GenericModal__button.confirm').should('not.be.disabled').type('{enter}', {force: true});
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @enterprise @messaging
|
||||
|
||||
import * as TIMEOUTS from '../../../fixtures/timeouts';
|
||||
|
||||
describe('Move thread', () => {
|
||||
let user1;
|
||||
let user2;
|
||||
let user3;
|
||||
let testTeam;
|
||||
let gmChannel;
|
||||
let gmChannelName;
|
||||
let testPost;
|
||||
let replyPost;
|
||||
|
||||
const message = 'Move this message';
|
||||
const replyMessage = 'Move this reply';
|
||||
|
||||
beforeEach(() => {
|
||||
cy.shouldHaveFeatureFlag('MoveThreadsEnabled', true);
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_on',
|
||||
},
|
||||
WranglerSettings: {
|
||||
MoveThreadFromGroupMessageChannelEnable: true,
|
||||
},
|
||||
});
|
||||
|
||||
// # Login as new user, create new team and visit its URL
|
||||
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({
|
||||
user,
|
||||
team,
|
||||
}) => {
|
||||
user1 = user;
|
||||
testTeam = team;
|
||||
|
||||
// # enable CRT for the user
|
||||
cy.apiSaveCRTPreference(user.id, 'on');
|
||||
|
||||
// # Create another user
|
||||
return cy.apiCreateUser({prefix: 'second_'});
|
||||
}).then(({user}) => {
|
||||
user2 = user;
|
||||
|
||||
// # Add other user to team
|
||||
return cy.apiAddUserToTeam(testTeam.id, user2.id);
|
||||
}).then(() => {
|
||||
// # Create another user
|
||||
return cy.apiCreateUser({prefix: 'third_'});
|
||||
}).then(({user}) => {
|
||||
user3 = user;
|
||||
|
||||
// # Add other user to team
|
||||
return cy.apiAddUserToTeam(testTeam.id, user3.id);
|
||||
}).then(() => {
|
||||
// # Create new GM channel
|
||||
return cy.apiCreateGroupChannel([user1.id, user2.id, user3.id]);
|
||||
}).then(({channel}) => {
|
||||
gmChannel = channel;
|
||||
gmChannelName = gmChannel.display_name.split(', ').filter(((username) => username !== user1.username)).join(', ');
|
||||
|
||||
// # Post a sample message
|
||||
return cy.postMessageAs({sender: user1, message, channelId: gmChannel.id});
|
||||
}).then((post) => {
|
||||
testPost = post.data;
|
||||
|
||||
// # Post a reply
|
||||
return cy.postMessageAs({sender: user1, message: replyMessage, channelId: gmChannel.id, rootId: testPost.id});
|
||||
}).then((post) => {
|
||||
replyPost = post.data;
|
||||
|
||||
// # Got to Test channel
|
||||
cy.visit(`/${testTeam.name}/channels/${gmChannel.name}`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// # Go to 1. public channel
|
||||
cy.visit(`/${testTeam.name}/channels/${gmChannel.name}`);
|
||||
});
|
||||
|
||||
it('Move post from GM (with at least 2 other users)', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
||||
// # Click on ... button of last post
|
||||
cy.clickPostDotMenu(testPost.id);
|
||||
|
||||
// * Assert availability of the Move menu-item
|
||||
cy.findByText('Move Thread').type('{shift}W');
|
||||
|
||||
// # Move Post
|
||||
moveThreadFromGM();
|
||||
|
||||
// * Assert switch to GM channel
|
||||
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', gmChannelName);
|
||||
|
||||
// * Assert post has been moved
|
||||
verifyMovedMessage({post: testPost});
|
||||
});
|
||||
|
||||
it('Move thread with replies from GM (with at least 2 other users)', () => {
|
||||
// # Open the RHS with replies to the root post
|
||||
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
|
||||
|
||||
// * Assert RHS is open
|
||||
cy.get('#rhsContainer').should('be.visible');
|
||||
|
||||
// # Click on ... button of reply post
|
||||
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
|
||||
|
||||
// * Assert availability of the Move menu-item
|
||||
cy.findByText('Move Thread').type('{shift}W');
|
||||
|
||||
// # Move Post
|
||||
moveThreadFromGM();
|
||||
|
||||
// * Assert switch to GM channel
|
||||
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', gmChannelName);
|
||||
|
||||
// * Assert post has been moved
|
||||
verifyMovedMessage({post: testPost});
|
||||
});
|
||||
|
||||
it('Move thread from GM (with at least 2 other users) - Cancel using X', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
||||
// # Click on ... button of last post
|
||||
cy.clickPostDotMenu(testPost.id);
|
||||
|
||||
// * Assert availability of the Move menu-item
|
||||
cy.findByText('Move Thread').type('{shift}W');
|
||||
|
||||
// # Move Post
|
||||
moveThreadFromGM({cancel: true});
|
||||
|
||||
// * Assert switch to GM channel
|
||||
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', gmChannelName);
|
||||
|
||||
// * Assert last post id is identical with testPost
|
||||
cy.getLastPostId((id) => {
|
||||
assert.isEqual(id, testPost.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('Should not be able to move post from GM if configured off', () => {
|
||||
cy.apiUpdateConfig({
|
||||
WranglerSettings: {
|
||||
MoveThreadFromGroupMessageChannelEnable: false,
|
||||
},
|
||||
});
|
||||
|
||||
// # Open the RHS with replies to the root post
|
||||
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
|
||||
|
||||
// * Assert RHS is open
|
||||
cy.get('#rhsContainer').should('be.visible');
|
||||
|
||||
// # Click on ... button of reply post
|
||||
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').should('not.exist');
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that the post has been moved
|
||||
*
|
||||
* @param {string?} comment
|
||||
* @param {boolean?} showMore
|
||||
* @param {Post} post
|
||||
*/
|
||||
const verifyMovedMessage = ({post, comment, showMore}) => {
|
||||
const permaLink = `${Cypress.config('baseUrl')}/${testTeam.name}/pl/${post.id}`;
|
||||
|
||||
// * Assert post has been moved
|
||||
cy.getLastPostId().then((id) => {
|
||||
// * Assert last post is visible
|
||||
cy.get(`#${id}_message`).should('be.visible').within(() => {
|
||||
if (comment) {
|
||||
// * Assert the text in the post body is the permalink only
|
||||
cy.get(`#postMessageText_${id}`).should('be.visible').should('contain.text', permaLink).should('contain.text', comment);
|
||||
|
||||
if (showMore) {
|
||||
// * Assert show more button is rendered and works as expected
|
||||
cy.get('#showMoreButton').should('be.visible').should('contain.text', 'Show more').click().should('contain.text', 'Show less').click();
|
||||
}
|
||||
}
|
||||
|
||||
// * Assert the text in the preview matches the original post message
|
||||
cy.get(`#postMessageText_${post.id}`).should('be.visible').should('contain.text', post.message);
|
||||
});
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(id);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Move thread
|
||||
*
|
||||
* @param {object?} options
|
||||
* @param {boolean?} options.cancel
|
||||
*/
|
||||
const moveThreadFromGM = ({cancel = false} = {}) => {
|
||||
// * Assert visibility of the move thread modal
|
||||
cy.get('#move-thread-modal').should('be.visible').within(() => {
|
||||
// * Assert channel select is not existent
|
||||
cy.get('.move-thread__select').should('not.exist');
|
||||
|
||||
// * Assert if button is enabled
|
||||
cy.get('.GenericModal__button.confirm').should('not.be.disabled');
|
||||
|
||||
// * Assert Notification is shown
|
||||
cy.findByTestId('notification-text').should('be.visible').should('contain.text', 'Moving this thread changes who has access');
|
||||
|
||||
if (cancel) {
|
||||
// * Assert if button is active
|
||||
cy.uiCloseModal('Move thread');
|
||||
} else {
|
||||
// * Assert if button is active
|
||||
cy.get('.GenericModal__button.confirm').should('not.be.disabled').type('{enter}', {force: true});
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @enterprise @messaging
|
||||
|
||||
import * as TIMEOUTS from '../../../fixtures/timeouts';
|
||||
|
||||
describe('Move thread', () => {
|
||||
let user1;
|
||||
let testTeam;
|
||||
let privateChannel;
|
||||
let testPost;
|
||||
let replyPost;
|
||||
|
||||
const message = 'Move this message';
|
||||
const replyMessage = 'Move this reply';
|
||||
|
||||
beforeEach(() => {
|
||||
cy.apiRequireLicense();
|
||||
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_on',
|
||||
},
|
||||
WranglerSettings: {
|
||||
MoveThreadFromPrivateChannelEnable: true,
|
||||
},
|
||||
});
|
||||
|
||||
// # Login as new user, create new team and visit its URL
|
||||
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({
|
||||
user,
|
||||
team,
|
||||
}) => {
|
||||
user1 = user;
|
||||
testTeam = team;
|
||||
|
||||
// # enable CRT for the user
|
||||
cy.apiSaveCRTPreference(user.id, 'on');
|
||||
|
||||
// # Create a private channel
|
||||
return cy.apiCreateChannel(testTeam.id, 'private', 'Private', 'P');
|
||||
}).then(({channel}) => {
|
||||
privateChannel = channel;
|
||||
|
||||
// # Post a sample message
|
||||
return cy.postMessageAs({sender: user1, message, channelId: privateChannel.id});
|
||||
}).then((post) => {
|
||||
testPost = post.data;
|
||||
|
||||
// # Post a reply
|
||||
return cy.postMessageAs({sender: user1, message: replyMessage, channelId: privateChannel.id, rootId: testPost.id});
|
||||
}).then((post) => {
|
||||
replyPost = post.data;
|
||||
|
||||
// # Got to Private channel
|
||||
cy.visit(`/${testTeam.name}/channels/${privateChannel.name}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('Move root post from private channel', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
||||
// # Click on ... button of last post
|
||||
cy.clickPostDotMenu(testPost.id);
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').type('{shift}W');
|
||||
|
||||
// # Move Thread
|
||||
moveThreadFromPrivateChannel();
|
||||
|
||||
// * Assert switch to testchannel
|
||||
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', privateChannel.display_name);
|
||||
|
||||
// * Assert post has been moved
|
||||
verifyMovedMessage({post: testPost});
|
||||
});
|
||||
|
||||
it('Move thread with replies from private channel', () => {
|
||||
// # Open the RHS with replies to the root post
|
||||
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
|
||||
|
||||
// * Assert RHS is open
|
||||
cy.get('#rhsContainer').should('be.visible');
|
||||
|
||||
// # Click on ... button of reply post
|
||||
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').type('{shift}W');
|
||||
|
||||
// # Move Thread
|
||||
moveThreadFromPrivateChannel();
|
||||
|
||||
// * Assert switch to testchannel
|
||||
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', privateChannel.display_name);
|
||||
|
||||
// * Assert post has been moved
|
||||
verifyMovedMessage({post: testPost});
|
||||
});
|
||||
|
||||
it('Move post from private channel - Cancel', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
||||
// # Click on ... button of last post
|
||||
cy.clickPostDotMenu(testPost.id);
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').type('{shift}W');
|
||||
|
||||
// # Move Thread
|
||||
moveThreadFromPrivateChannel(true);
|
||||
|
||||
// * Assert switch to testchannel
|
||||
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', privateChannel.display_name);
|
||||
|
||||
// * Assert last post id is identical with testPost
|
||||
cy.getLastPostId((id) => {
|
||||
assert.isEqual(id, testPost.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('Should not be able to move post from private channel if configured off', () => {
|
||||
cy.apiUpdateConfig({
|
||||
WranglerSettings: {
|
||||
MoveThreadFromPrivateChannelEnable: false,
|
||||
},
|
||||
});
|
||||
|
||||
// # Open the RHS with replies to the root post
|
||||
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
|
||||
|
||||
// * Assert RHS is open
|
||||
cy.get('#rhsContainer').should('be.visible');
|
||||
|
||||
// # Click on ... button of reply post
|
||||
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').should('not.exist');
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that the post has been moved
|
||||
*
|
||||
* @param {Post} post
|
||||
*/
|
||||
const verifyMovedMessage = ({post}) => {
|
||||
// * Assert post has been moved
|
||||
cy.getLastPostId().then((id) => {
|
||||
// * Assert last post is visible
|
||||
cy.get(`#${id}_message`).should('be.visible').within(() => {
|
||||
// * Assert the text in the preview matches the original post message
|
||||
cy.get(`#postMessageText_${post.id}`).should('be.visible').should('contain.text', post.message);
|
||||
});
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(id);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Move thread
|
||||
*
|
||||
* @param {boolean?} cancel
|
||||
*/
|
||||
const moveThreadFromPrivateChannel = (cancel = false) => {
|
||||
// * Assert visibility of the move thread modal
|
||||
cy.get('#move-thread-modal').should('be.visible').within(() => {
|
||||
// * Assert channel select is not existent
|
||||
cy.get('.move-thread__select').should('not.exist');
|
||||
|
||||
// * Assert if button is enabled
|
||||
cy.get('.GenericModal__button.confirm').should('not.be.disabled');
|
||||
|
||||
// * Assert Notification is shown
|
||||
cy.findByTestId('notification-text').should('be.visible').should('contain.text', 'Moving this thread changes who has access');
|
||||
|
||||
if (cancel) {
|
||||
// * Assert if button is active
|
||||
cy.get('.MoveThreadModal__cancel-button').should('not.be.disabled').click();
|
||||
} else {
|
||||
// * Assert if button is active
|
||||
cy.get('.GenericModal__button.confirm').should('not.be.disabled').click();
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @enterprise @messaging
|
||||
|
||||
describe('Move Thread', () => {
|
||||
let user1;
|
||||
let user2;
|
||||
let user3;
|
||||
let testTeam;
|
||||
let testChannel;
|
||||
let otherChannel;
|
||||
let privateChannel;
|
||||
let dmChannel;
|
||||
let gmChannel;
|
||||
let testPost;
|
||||
let replyPost;
|
||||
|
||||
const message = 'Move this message';
|
||||
const replyMessage = 'Move this reply';
|
||||
|
||||
beforeEach(() => {
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_on',
|
||||
},
|
||||
});
|
||||
|
||||
// # Login as new user, create new team and visit its URL
|
||||
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({
|
||||
user,
|
||||
team,
|
||||
channel,
|
||||
}) => {
|
||||
user1 = user;
|
||||
testTeam = team;
|
||||
testChannel = channel;
|
||||
|
||||
// # enable CRT for the user
|
||||
cy.apiSaveCRTPreference(user.id, 'on');
|
||||
|
||||
// # Create another user
|
||||
return cy.apiCreateUser({prefix: 'second_'});
|
||||
}).then(({user}) => {
|
||||
user2 = user;
|
||||
|
||||
// # Add other user to team
|
||||
return cy.apiAddUserToTeam(testTeam.id, user2.id);
|
||||
}).then(() => {
|
||||
// # Create another user
|
||||
return cy.apiCreateUser({prefix: 'third_'});
|
||||
}).then(({user}) => {
|
||||
user3 = user;
|
||||
|
||||
// # Add other user to team
|
||||
return cy.apiAddUserToTeam(testTeam.id, user3.id);
|
||||
}).then(() => {
|
||||
cy.apiAddUserToChannel(testChannel.id, user2.id);
|
||||
cy.apiAddUserToChannel(testChannel.id, user3.id);
|
||||
|
||||
// # Post a sample message
|
||||
return cy.postMessageAs({sender: user1, message, channelId: testChannel.id});
|
||||
}).then((post) => {
|
||||
testPost = post.data;
|
||||
|
||||
// # Post a reply
|
||||
return cy.postMessageAs({sender: user1, message: replyMessage, channelId: testChannel.id, rootId: testPost.id});
|
||||
}).then((post) => {
|
||||
replyPost = post.data;
|
||||
|
||||
// # Create new DM channel
|
||||
return cy.apiCreateDirectChannel([user1.id, user2.id]);
|
||||
}).then(({channel}) => {
|
||||
dmChannel = channel;
|
||||
|
||||
// # Create new DM channel
|
||||
return cy.apiCreateGroupChannel([user1.id, user2.id, user3.id]);
|
||||
}).then(({channel}) => {
|
||||
gmChannel = channel;
|
||||
console.log('this one');
|
||||
|
||||
// # Create a private channel to Move Thread to
|
||||
return cy.apiCreateChannel(testTeam.id, 'private', 'Private');
|
||||
}).then(({channel}) => {
|
||||
privateChannel = channel;
|
||||
console.log('no, this one');
|
||||
|
||||
// # Create a second channel to Move Thread to
|
||||
return cy.apiCreateChannel(testTeam.id, 'movethread', 'Move Thread');
|
||||
}).then(({channel}) => {
|
||||
otherChannel = channel;
|
||||
|
||||
// # Got to Test channel
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('Move root post from public channel to another public channel', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
||||
// # Click on ... button of last post
|
||||
cy.clickPostDotMenu(testPost.id);
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').click();
|
||||
|
||||
// # Move Thread
|
||||
moveThread({channelId: otherChannel.id});
|
||||
|
||||
// * Assert post has been moved
|
||||
verifyMovedMessage({post: testPost});
|
||||
});
|
||||
|
||||
it('Move reply post from public channel to another public channel', () => {
|
||||
// # Open the RHS with replies to the root post
|
||||
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
|
||||
|
||||
// * Assert RHS is open
|
||||
cy.get('#rhsContainer').should('be.visible');
|
||||
|
||||
// # Click on ... button of reply post
|
||||
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').click();
|
||||
|
||||
// * Move Thread
|
||||
moveThread({channelId: otherChannel.id});
|
||||
|
||||
// * Assert post has been moved
|
||||
verifyMovedMessage({post: testPost});
|
||||
});
|
||||
|
||||
it('Move public channel post to Private channel', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
||||
// # Click on ... button of last post
|
||||
cy.clickPostDotMenu(testPost.id);
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').click();
|
||||
|
||||
// # Move Thread
|
||||
moveThread({channelId: privateChannel.id});
|
||||
|
||||
// * Assert post has been moved
|
||||
verifyMovedMessage({post: testPost});
|
||||
});
|
||||
|
||||
it('Move public channel post to GM', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
||||
// # Click on ... button of last post
|
||||
cy.clickPostDotMenu(testPost.id);
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').click();
|
||||
|
||||
// # Move Thread
|
||||
moveThread({channelId: gmChannel.id});
|
||||
|
||||
// * Assert post has been moved
|
||||
verifyMovedMessage({post: testPost});
|
||||
});
|
||||
|
||||
it('Move public channel post to DM', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
||||
// # Click on ... button of last post
|
||||
cy.clickPostDotMenu(testPost.id);
|
||||
|
||||
// * Assert availability of the Move Thread menu-item
|
||||
cy.findByText('Move Thread').click();
|
||||
|
||||
// # Move Thread
|
||||
moveThread({channelId: dmChannel.id});
|
||||
|
||||
// * Assert post has been moved
|
||||
verifyMovedMessage({post: testPost});
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that the post has been moved
|
||||
*
|
||||
* @param {Post} post
|
||||
*/
|
||||
const verifyMovedMessage = ({post}) => {
|
||||
// * Assert post has been moved
|
||||
cy.getLastPostId().then((id) => {
|
||||
// * Assert last post is visible
|
||||
cy.get(`#${id}_message`).should('be.visible').within(() => {
|
||||
// * Assert the text in the preview matches the original post message
|
||||
cy.get(`#postMessageText_${post.id}`).should('be.visible').should('contain.text', post.message);
|
||||
});
|
||||
|
||||
// # Cleanup
|
||||
cy.apiDeletePost(id);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Move Thread with optional comment.
|
||||
*
|
||||
*/
|
||||
const moveThread = () => {
|
||||
// * Assert visibility of the Move Thread modal
|
||||
cy.get('#move-thread-modal').should('be.visible').within(() => {
|
||||
// * Assert channel select is not existent
|
||||
cy.get('.move-thread__select').should('not.exist');
|
||||
|
||||
// * Assert if button is enabled
|
||||
cy.get('.GenericModal__button.confirm').should('not.be.disabled');
|
||||
|
||||
// * Assert Notification is shown
|
||||
cy.findByTestId('notification-text').should('be.visible').should('contain.text', 'Moving this thread changes who has access');
|
||||
});
|
||||
};
|
||||
});
|
||||
|
|
@ -714,4 +714,13 @@ const defaultServerConfig: AdminConfig = {
|
|||
Directory: './export',
|
||||
RetentionDays: 30,
|
||||
},
|
||||
WranglerSettings: {
|
||||
PermittedWranglerRoles: [],
|
||||
AllowedEmailDomain: [],
|
||||
MoveThreadMaxCount: 30,
|
||||
MoveThreadToAnotherTeamEnable: true,
|
||||
MoveThreadFromPrivateChannelEnable: true,
|
||||
MoveThreadFromDirectMessageChannelEnable: true,
|
||||
MoveThreadFromGroupMessageChannelEnable: true,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ func (api *API) InitPost() {
|
|||
|
||||
api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(acknowledgePost)).Methods("POST")
|
||||
api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(unacknowledgePost)).Methods("DELETE")
|
||||
|
||||
api.BaseRoutes.Post.Handle("/move", api.APISessionRequired(moveThread)).Methods("POST")
|
||||
}
|
||||
|
||||
func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -1128,6 +1130,83 @@ func unacknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
func moveThread(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !c.App.Config().FeatureFlags.MoveThreadsEnabled {
|
||||
c.Err = model.NewAppError("moveThread", "api.post.move_thread.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
var moveThreadParams model.MoveThreadParams
|
||||
if jsonErr := json.NewDecoder(r.Body).Decode(&moveThreadParams); jsonErr != nil {
|
||||
c.SetInvalidParamWithErr("post", jsonErr)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord("moveThread", audit.Fail)
|
||||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
audit.AddEventParameter(auditRec, "original_post_id", c.Params.PostId)
|
||||
audit.AddEventParameter(auditRec, "to_channel_id", moveThreadParams.ChannelId)
|
||||
|
||||
user, err := c.App.GetUser(c.AppContext.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
// If there are no configured PermittedWranglerRoles, skip the check
|
||||
userHasRole := len(c.App.Config().WranglerSettings.PermittedWranglerRoles) == 0
|
||||
for _, role := range c.App.Config().WranglerSettings.PermittedWranglerRoles {
|
||||
if user.IsInRole(role) {
|
||||
userHasRole = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Sysadmins are always permitted
|
||||
if !userHasRole && !user.IsSystemAdmin() {
|
||||
c.Err = model.NewAppError("moveThread", "api.post.move_thread.no_permission", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
userHasEmailDomain := len(c.App.Config().WranglerSettings.AllowedEmailDomain) == 0
|
||||
for _, domain := range c.App.Config().WranglerSettings.AllowedEmailDomain {
|
||||
if user.EmailDomain() == domain {
|
||||
userHasEmailDomain = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !userHasEmailDomain && !user.IsSystemAdmin() {
|
||||
c.Err = model.NewAppError("moveThread", "api.post.move_thread.no_permission", nil, fmt.Sprintf("User: %+v", user), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
sourcePost, err := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), false)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
if err.Id == "app.post.cloud.get.app_error" {
|
||||
w.Header().Set(model.HeaderFirstInaccessiblePostTime, "1")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = c.App.MoveThread(c.AppContext, c.Params.PostId, sourcePost.ChannelId, moveThreadParams.ChannelId, user)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
|
|
|
|||
|
|
@ -730,6 +730,239 @@ func TestCreatePostWithOutgoingHook_no_content_type(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestMoveThread(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_MOVETHREADSENABLED", "true")
|
||||
defer os.Unsetenv("MM_FEATUREFLAGS_MOVETHREADSENABLED")
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
client := th.Client
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
basicUser1 := th.BasicUser
|
||||
basicUser2 := th.BasicUser2
|
||||
basicUser3 := th.CreateUser()
|
||||
|
||||
// Create a new public channel to move the post to
|
||||
publicChannel, resp, err := client.CreateChannel(ctx, &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "test-public-channel",
|
||||
DisplayName: "Test Public Channel",
|
||||
Type: model.ChannelTypeOpen,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, publicChannel)
|
||||
|
||||
// Create a new private channel to move the post to
|
||||
privateChannel, resp, err := client.CreateChannel(ctx, &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "test-private-channel",
|
||||
DisplayName: "Test Private Channel",
|
||||
Type: model.ChannelTypePrivate,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, privateChannel)
|
||||
|
||||
// Create a new direct message channel to move the post to
|
||||
dmChannel, resp, err := client.CreateDirectChannel(ctx, basicUser1.Id, basicUser2.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, dmChannel)
|
||||
|
||||
// Create a new group message channel to move the post to
|
||||
gmChannel, resp, err := client.CreateGroupChannel(ctx, []string{basicUser1.Id, basicUser2.Id, basicUser3.Id})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, gmChannel)
|
||||
t.Run("Move to public channel", func(t *testing.T) {
|
||||
// Create a new post to move
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post",
|
||||
}
|
||||
newPost, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost)
|
||||
|
||||
// Move the post to the public channel
|
||||
moveThreadParams := &model.MoveThreadParams{
|
||||
ChannelId: publicChannel.Id,
|
||||
}
|
||||
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check that the post was moved to the public channel
|
||||
posts, resp, err := client.GetPostsForChannel(ctx, publicChannel.Id, 0, 100, "", true, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, posts)
|
||||
// There should be 2 posts, the system join message for the user who moved it joining the channel, and the post we moved
|
||||
require.Equal(t, 2, len(posts.Posts))
|
||||
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
|
||||
})
|
||||
|
||||
t.Run("Move to private channel", func(t *testing.T) {
|
||||
// Create a new post to move
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post",
|
||||
}
|
||||
newPost, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost)
|
||||
|
||||
// Move the post to the private channel
|
||||
moveThreadParams := &model.MoveThreadParams{
|
||||
ChannelId: privateChannel.Id,
|
||||
}
|
||||
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check that the post was moved to the private channel
|
||||
posts, resp, err := client.GetPostsForChannel(ctx, privateChannel.Id, 0, 100, "", true, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, posts)
|
||||
// There should be 2 posts, the system join message for the user who moved it joining the channel, and the post we moved
|
||||
require.Equal(t, 2, len(posts.Posts))
|
||||
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
|
||||
})
|
||||
|
||||
t.Run("Move to direct message channel", func(t *testing.T) {
|
||||
// Create a new post to move
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post",
|
||||
}
|
||||
newPost, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost)
|
||||
|
||||
// Move the post to the direct message channel
|
||||
moveThreadParams := &model.MoveThreadParams{
|
||||
ChannelId: dmChannel.Id,
|
||||
}
|
||||
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check that the post was moved to the direct message channel
|
||||
posts, resp, err := client.GetPostsForChannel(ctx, dmChannel.Id, 0, 100, "", true, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, posts)
|
||||
// There should be 1 post, the post we moved
|
||||
require.Equal(t, 1, len(posts.Posts))
|
||||
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
|
||||
})
|
||||
|
||||
t.Run("Move to group message channel", func(t *testing.T) {
|
||||
// Create a new post to move
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post",
|
||||
}
|
||||
newPost, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost)
|
||||
|
||||
// Move the post to the group message channel
|
||||
moveThreadParams := &model.MoveThreadParams{
|
||||
ChannelId: gmChannel.Id,
|
||||
}
|
||||
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check that the post was moved to the group message channel
|
||||
posts, resp, err := client.GetPostsForChannel(ctx, gmChannel.Id, 0, 100, "", true, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, posts)
|
||||
// There should be 1 post, the post we moved
|
||||
require.Equal(t, 1, len(posts.Posts))
|
||||
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
|
||||
})
|
||||
|
||||
t.Run("Move thread with more than one post", func(t *testing.T) {
|
||||
// Create a new public channel to move the post to
|
||||
pChannel, resp, err := client.CreateChannel(ctx, &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "test-public-channel2",
|
||||
DisplayName: "Test Public Channel",
|
||||
Type: model.ChannelTypeOpen,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, pChannel)
|
||||
// Create a new post to use as the root post
|
||||
rootPost := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "root post",
|
||||
}
|
||||
rootPost, resp, err = client.CreatePost(ctx, rootPost)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, rootPost)
|
||||
|
||||
// Create a new post to move
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post",
|
||||
RootId: rootPost.Id,
|
||||
}
|
||||
newPost, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost)
|
||||
|
||||
// Create another post in the thread
|
||||
post = &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test post 2",
|
||||
RootId: rootPost.Id,
|
||||
}
|
||||
newPost2, resp, err := client.CreatePost(ctx, post)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, newPost2)
|
||||
|
||||
// Move the thread to the public channel
|
||||
moveThreadParams := &model.MoveThreadParams{
|
||||
ChannelId: pChannel.Id,
|
||||
}
|
||||
resp, err = client.MoveThread(ctx, rootPost.Id, moveThreadParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Check that the thread was moved to the public channel
|
||||
posts, resp, err := client.GetPostsForChannel(ctx, pChannel.Id, 0, 100, "", false, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, posts)
|
||||
// There should be 3 posts, the system join message for the user who moved it joining the channel, and the two posts in the thread
|
||||
// require.Equal(t, 3, len(posts.Posts))
|
||||
fmt.Println(posts.Order)
|
||||
for _, p := range posts.Order {
|
||||
fmt.Println(posts.Posts[p].Id)
|
||||
fmt.Println(posts.Posts[p].Message)
|
||||
}
|
||||
require.Equal(t, "This thread was moved from another channel", posts.Posts[posts.Order[0]].Message)
|
||||
require.Equal(t, newPost2.Message, posts.Posts[posts.Order[1]].Message)
|
||||
require.Equal(t, newPost.Message, posts.Posts[posts.Order[2]].Message)
|
||||
require.Equal(t, rootPost.Message, posts.Posts[posts.Order[3]].Message)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreatePostPublic(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
|
|
|||
|
|
@ -398,6 +398,10 @@ type AppIface interface {
|
|||
ValidateUserPermissionsOnChannels(c request.CTX, userId string, channelIds []string) []string
|
||||
// VerifyPlugin checks that the given signature corresponds to the given plugin and matches a trusted certificate.
|
||||
VerifyPlugin(plugin, signature io.ReadSeeker) *model.AppError
|
||||
// validateMoveOrCopy performs validation on a provided post list to determine
|
||||
// if all permissions are in place to allow the for the posts to be moved or
|
||||
// copied.
|
||||
ValidateMoveOrCopy(c request.CTX, wpl *model.WranglerPostList, originalChannel *model.Channel, targetChannel *model.Channel, user *model.User) error
|
||||
AccountMigration() einterfaces.AccountMigrationInterface
|
||||
ActivateMfa(userID, token string) *model.AppError
|
||||
ActiveSearchBackend() string
|
||||
|
|
@ -478,6 +482,7 @@ type AppIface interface {
|
|||
Config() *model.Config
|
||||
ConvertGroupMessageToChannel(c request.CTX, convertedByUserId string, gmConversionRequest *model.GroupMessageConversionRequestBody) (*model.Channel, *model.AppError)
|
||||
CopyFileInfos(rctx request.CTX, userID string, fileIDs []string) ([]string, *model.AppError)
|
||||
CopyWranglerPostlist(c request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError)
|
||||
CreateChannel(c request.CTX, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError)
|
||||
CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError)
|
||||
CreateCommand(cmd *model.Command) (*model.Command, *model.AppError)
|
||||
|
|
@ -918,6 +923,7 @@ type AppIface interface {
|
|||
MigrateIdLDAP(c request.CTX, toAttribute string) *model.AppError
|
||||
MoveCommand(team *model.Team, command *model.Command) *model.AppError
|
||||
MoveFile(oldPath, newPath string) *model.AppError
|
||||
MoveThread(c request.CTX, postID string, sourceChannelID, channelID string, user *model.User) *model.AppError
|
||||
NewPluginAPI(c request.CTX, manifest *model.Manifest) plugin.API
|
||||
Notification() einterfaces.NotificationInterface
|
||||
NotificationsLog() *mlog.Logger
|
||||
|
|
|
|||
|
|
@ -1884,6 +1884,28 @@ func (a *OpenTracingAppLayer) CopyFileInfos(rctx request.CTX, userID string, fil
|
|||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CopyWranglerPostlist(c request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CopyWranglerPostlist")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.CopyWranglerPostlist(c, wpl, targetChannel)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CreateBot(c request.CTX, bot *model.Bot) (*model.Bot, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateBot")
|
||||
|
|
@ -12664,6 +12686,28 @@ func (a *OpenTracingAppLayer) MoveFile(oldPath string, newPath string) *model.Ap
|
|||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) MoveThread(c request.CTX, postID string, sourceChannelID string, channelID string, user *model.User) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.MoveThread")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.MoveThread(c, postID, sourceChannelID, channelID, user)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) NewPluginAPI(c request.CTX, manifest *model.Manifest) plugin.API {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.NewPluginAPI")
|
||||
|
|
@ -18590,6 +18634,28 @@ func (a *OpenTracingAppLayer) ValidateDesktopToken(token string, expiryTime int6
|
|||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) ValidateMoveOrCopy(c request.CTX, wpl *model.WranglerPostList, originalChannel *model.Channel, targetChannel *model.Channel, user *model.User) error {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ValidateMoveOrCopy")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.ValidateMoveOrCopy(c, wpl, originalChannel, targetChannel, user)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) ValidateUserPermissionsOnChannels(c request.CTX, userId string, channelIds []string) []string {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ValidateUserPermissionsOnChannels")
|
||||
|
|
|
|||
|
|
@ -2349,3 +2349,255 @@ func (a *App) applyPostWillBeConsumedHook(post **model.Post) {
|
|||
return true
|
||||
}, plugin.MessagesWillBeConsumedID)
|
||||
}
|
||||
|
||||
func makePostLink(siteURL, teamName, postID string) string {
|
||||
return fmt.Sprintf("%s/%s/pl/%s", siteURL, teamName, postID)
|
||||
}
|
||||
|
||||
// validateMoveOrCopy performs validation on a provided post list to determine
|
||||
// if all permissions are in place to allow the for the posts to be moved or
|
||||
// copied.
|
||||
func (a *App) ValidateMoveOrCopy(c request.CTX, wpl *model.WranglerPostList, originalChannel *model.Channel, targetChannel *model.Channel, user *model.User) error {
|
||||
if wpl.NumPosts() == 0 {
|
||||
return errors.New("The wrangler post list contains no posts")
|
||||
}
|
||||
|
||||
config := a.Config().WranglerSettings
|
||||
|
||||
switch originalChannel.Type {
|
||||
case model.ChannelTypePrivate:
|
||||
if !*config.MoveThreadFromPrivateChannelEnable {
|
||||
return errors.New("Wrangler is currently configured to not allow moving posts from private channels")
|
||||
}
|
||||
case model.ChannelTypeDirect:
|
||||
if !*config.MoveThreadFromDirectMessageChannelEnable {
|
||||
return errors.New("Wrangler is currently configured to not allow moving posts from direct message channels")
|
||||
}
|
||||
case model.ChannelTypeGroup:
|
||||
if !*config.MoveThreadFromGroupMessageChannelEnable {
|
||||
return errors.New("Wrangler is currently configured to not allow moving posts from group message channels")
|
||||
}
|
||||
}
|
||||
|
||||
if !originalChannel.IsGroupOrDirect() && !targetChannel.IsGroupOrDirect() {
|
||||
// DM and GM channels are "teamless" so it doesn't make sense to check
|
||||
// the MoveThreadToAnotherTeamEnable config when dealing with those.
|
||||
if !*config.MoveThreadToAnotherTeamEnable && targetChannel.TeamId != originalChannel.TeamId {
|
||||
return errors.New("Wrangler is currently configured to not allow moving messages to different teams")
|
||||
}
|
||||
}
|
||||
|
||||
if *config.MoveThreadMaxCount != int64(0) && *config.MoveThreadMaxCount < int64(wpl.NumPosts()) {
|
||||
return fmt.Errorf("the thread is %d posts long, but this command is configured to only move threads of up to %d posts", wpl.NumPosts(), *config.MoveThreadMaxCount)
|
||||
}
|
||||
|
||||
_, appErr := a.GetChannelMember(c, targetChannel.Id, user.Id)
|
||||
if appErr != nil {
|
||||
return fmt.Errorf("channel with ID %s doesn't exist or you are not a member", targetChannel.Id)
|
||||
}
|
||||
|
||||
_, appErr = a.GetChannelMember(c, originalChannel.Id, user.Id)
|
||||
if appErr != nil {
|
||||
return fmt.Errorf("channel with ID %s doesn't exist or you are not a member", originalChannel.Id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CopyWranglerPostlist(c request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError) {
|
||||
var appErr *model.AppError
|
||||
var newRootPost *model.Post
|
||||
|
||||
if wpl.ContainsFileAttachments() {
|
||||
// The thread contains at least one attachment. To properly move the
|
||||
// thread, the files will have to be re-uploaded. This is completed
|
||||
// before any messages are moved.
|
||||
// TODO: check number of files that need to be re-uploaded or file size?
|
||||
c.Logger().Info("Wrangler is re-uploading file attachments",
|
||||
mlog.String("file_count", fmt.Sprintf("%d", wpl.FileAttachmentCount)),
|
||||
)
|
||||
|
||||
for _, post := range wpl.Posts {
|
||||
var newFileIDs []string
|
||||
var fileBytes []byte
|
||||
var oldFileInfo, newFileInfo *model.FileInfo
|
||||
for _, fileID := range post.FileIds {
|
||||
oldFileInfo, appErr = a.GetFileInfo(c, fileID)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
fileBytes, appErr = a.GetFile(c, fileID)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
newFileInfo, appErr = a.UploadFile(c, fileBytes, targetChannel.Id, oldFileInfo.Name)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
newFileIDs = append(newFileIDs, newFileInfo.Id)
|
||||
}
|
||||
|
||||
post.FileIds = newFileIDs
|
||||
}
|
||||
}
|
||||
|
||||
for i, post := range wpl.Posts {
|
||||
var reactions []*model.Reaction
|
||||
|
||||
// Store reactions to be reapplied later.
|
||||
reactions, appErr = a.GetReactionsForPost(post.Id)
|
||||
if appErr != nil {
|
||||
// Reaction-based errors are logged, but do not abort
|
||||
c.Logger().Error("Failed to get reactions on original post")
|
||||
}
|
||||
|
||||
newPost := post.Clone()
|
||||
newPost = newPost.CleanPost()
|
||||
newPost.ChannelId = targetChannel.Id
|
||||
|
||||
if i == 0 {
|
||||
newPost, appErr = a.CreatePost(c, newPost, targetChannel, false, false)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
newRootPost = newPost.Clone()
|
||||
} else {
|
||||
newPost.RootId = newRootPost.Id
|
||||
newPost, appErr = a.CreatePost(c, newPost, targetChannel, false, false)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
}
|
||||
|
||||
for _, reaction := range reactions {
|
||||
reaction.PostId = newPost.Id
|
||||
_, appErr = a.SaveReactionForPost(c, reaction)
|
||||
if appErr != nil {
|
||||
// Reaction-based errors are logged, but do not abort
|
||||
c.Logger().Error("Failed to reapply reactions to post")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newRootPost, nil
|
||||
}
|
||||
|
||||
func (a *App) MoveThread(c request.CTX, postID string, sourceChannelID, channelID string, user *model.User) *model.AppError {
|
||||
postListResponse, appErr := a.GetPostThread(postID, model.GetPostsOptions{}, user.Id)
|
||||
if appErr != nil {
|
||||
return model.NewAppError("getPostThread", "app.post.move_thread_command.error", nil, "postID="+postID+", "+"UserId="+user.Id+"", http.StatusBadRequest)
|
||||
}
|
||||
wpl := postListResponse.BuildWranglerPostList()
|
||||
|
||||
originalChannel, appErr := a.GetChannel(c, sourceChannelID)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
targetChannel, appErr := a.GetChannel(c, channelID)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
err := a.ValidateMoveOrCopy(c, wpl, originalChannel, targetChannel, user)
|
||||
if err != nil {
|
||||
return model.NewAppError("validateMoveOrCopy", "app.post.move_thread_command.error", nil, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var targetTeam *model.Team
|
||||
if targetChannel.IsGroupOrDirect() {
|
||||
if !originalChannel.IsGroupOrDirect() {
|
||||
targetTeam, appErr = a.GetTeam(originalChannel.TeamId)
|
||||
}
|
||||
} else {
|
||||
targetTeam, appErr = a.GetTeam(targetChannel.TeamId)
|
||||
}
|
||||
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
if targetTeam == nil {
|
||||
return model.NewAppError("validateMoveOrCopy", "app.post.move_thread_command.error", nil, "target team is nil", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Begin creating the new thread.
|
||||
c.Logger().Info("Wrangler is moving a thread", mlog.String("user_id", user.Id), mlog.String("original_post_id", wpl.RootPost().Id), mlog.String("original_channel_id", originalChannel.Id))
|
||||
|
||||
// To simulate the move, we first copy the original messages(s) to the
|
||||
// new channel and later delete the original messages(s).
|
||||
newRootPost, appErr := a.CopyWranglerPostlist(c, wpl, targetChannel)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
T, err := i18n.GetTranslationsBySystemLocale()
|
||||
if err != nil {
|
||||
return model.NewAppError("MoveThread", "app.post.move_thread_command.error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
ephemeralPostProps := model.StringInterface{
|
||||
"TranslationID": "app.post.move_thread.from_another_channel",
|
||||
}
|
||||
_, appErr = a.CreatePost(c, &model.Post{
|
||||
UserId: user.Id,
|
||||
Type: model.PostTypeWrangler,
|
||||
RootId: newRootPost.Id,
|
||||
ChannelId: channelID,
|
||||
Message: T("app.post.move_thread.from_another_channel"),
|
||||
Props: ephemeralPostProps,
|
||||
}, targetChannel, false, false)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
// Cleanup is handled by simply deleting the root post. Any comments/replies
|
||||
// are automatically marked as deleted for us.
|
||||
_, appErr = a.DeletePost(c, wpl.RootPost().Id, user.Id)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
c.Logger().Info("Wrangler thread move complete", mlog.String("user_id", user.Id), mlog.String("new_post_id", newRootPost.Id), mlog.String("channel_id", channelID))
|
||||
|
||||
// Translate to the system locale, webapp will attempt to render in each user's specific locale (based on the TranslationID prop) before falling back on the initiating user's locale
|
||||
ephemeralPostProps = model.StringInterface{}
|
||||
|
||||
msg := T("app.post.move_thread_command.direct_or_group.multiple_messages", model.StringInterface{"NumMessages": wpl.NumPosts()})
|
||||
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.direct_or_group.multiple_messages"
|
||||
if wpl.NumPosts() == 1 {
|
||||
msg = T("app.post.move_thread_command.direct_or_group.one_message")
|
||||
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.direct_or_group.one_message"
|
||||
}
|
||||
|
||||
if targetChannel.TeamId != "" {
|
||||
targetTeam, teamErr := a.GetTeam(targetChannel.TeamId)
|
||||
if teamErr != nil {
|
||||
return teamErr
|
||||
}
|
||||
targetName := targetTeam.Name
|
||||
newPostLink := makePostLink(*a.Config().ServiceSettings.SiteURL, targetName, newRootPost.Id)
|
||||
msg = T("app.post.move_thread_command.channel.multiple_messages", model.StringInterface{"NumMessages": wpl.NumPosts(), "Link": newPostLink})
|
||||
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.channel.multiple_messages"
|
||||
if wpl.NumPosts() == 1 {
|
||||
msg = T("app.post.move_thread_command.channel.one_message", model.StringInterface{"Link": newPostLink})
|
||||
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.channel.one_message"
|
||||
}
|
||||
ephemeralPostProps["MovedThreadPermalink"] = newPostLink
|
||||
}
|
||||
|
||||
ephemeralPostProps["NumMessages"] = wpl.NumPosts()
|
||||
|
||||
_, appErr = a.CreatePost(c, &model.Post{
|
||||
UserId: user.Id,
|
||||
Type: model.PostTypeWrangler,
|
||||
ChannelId: originalChannel.Id,
|
||||
Message: msg,
|
||||
Props: ephemeralPostProps,
|
||||
}, originalChannel, false, false)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
c.Logger().Info(msg)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3286,3 +3286,179 @@ func TestGetEditHistoryForPost(t *testing.T) {
|
|||
require.Empty(t, edits)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCopyWranglerPostlist(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
// Create a post with a file attachment
|
||||
fileBytes := []byte("file contents")
|
||||
fileInfo, err := th.App.UploadFile(th.Context, fileBytes, th.BasicChannel.Id, "file.txt")
|
||||
require.Nil(t, err)
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "test message",
|
||||
UserId: th.BasicUser.Id,
|
||||
FileIds: []string{fileInfo.Id},
|
||||
}
|
||||
rootPost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, false, true)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Add a reaction to the post
|
||||
reaction := &model.Reaction{
|
||||
UserId: th.BasicUser.Id,
|
||||
PostId: rootPost.Id,
|
||||
EmojiName: "smile",
|
||||
}
|
||||
_, err = th.App.SaveReactionForPost(th.Context, reaction)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Copy the post to a new channel
|
||||
targetChannel := &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "test-channel",
|
||||
Type: model.ChannelTypeOpen,
|
||||
}
|
||||
targetChannel, err = th.App.CreateChannel(th.Context, targetChannel, false)
|
||||
require.Nil(t, err)
|
||||
wpl := &model.WranglerPostList{
|
||||
Posts: []*model.Post{rootPost},
|
||||
FileAttachmentCount: 1,
|
||||
}
|
||||
newRootPost, err := th.App.CopyWranglerPostlist(th.Context, wpl, targetChannel)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Check that the new post has the same message and file attachment
|
||||
require.Equal(t, rootPost.Message, newRootPost.Message)
|
||||
require.Len(t, newRootPost.FileIds, 1)
|
||||
|
||||
// Check that the new post has the same reaction
|
||||
reactions, err := th.App.GetReactionsForPost(newRootPost.Id)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, reactions, 1)
|
||||
require.Equal(t, reaction.EmojiName, reactions[0].EmojiName)
|
||||
}
|
||||
|
||||
func TestValidateMoveOrCopy(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.WranglerSettings.MoveThreadFromPrivateChannelEnable = model.NewBool(true)
|
||||
cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable = model.NewBool(true)
|
||||
cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable = model.NewBool(true)
|
||||
cfg.WranglerSettings.MoveThreadToAnotherTeamEnable = model.NewBool(true)
|
||||
cfg.WranglerSettings.MoveThreadMaxCount = model.NewInt64(100)
|
||||
})
|
||||
|
||||
t.Run("empty post list", func(t *testing.T) {
|
||||
err := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{}, th.BasicChannel, th.BasicChannel, th.BasicUser)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "The wrangler post list contains no posts", err.Error())
|
||||
})
|
||||
|
||||
t.Run("moving from private channel with MoveThreadFromPrivateChannelEnable disabled", func(t *testing.T) {
|
||||
privateChannel := &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "private-channel",
|
||||
Type: model.ChannelTypePrivate,
|
||||
}
|
||||
privateChannel, err := th.App.CreateChannel(th.Context, privateChannel, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.WranglerSettings.MoveThreadFromPrivateChannelEnable = model.NewBool(false)
|
||||
})
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: privateChannel.Id}}}, privateChannel, th.BasicChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, "Wrangler is currently configured to not allow moving posts from private channels", e.Error())
|
||||
})
|
||||
|
||||
t.Run("moving from direct channel with MoveThreadFromDirectMessageChannelEnable disabled", func(t *testing.T) {
|
||||
directChannel, err := th.App.createDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, directChannel)
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable = model.NewBool(false)
|
||||
})
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: directChannel.Id}}}, directChannel, th.BasicChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, "Wrangler is currently configured to not allow moving posts from direct message channels", e.Error())
|
||||
})
|
||||
|
||||
t.Run("moving from group channel with MoveThreadFromGroupMessageChannelEnable disabled", func(t *testing.T) {
|
||||
groupChannel := &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "group-channel",
|
||||
Type: model.ChannelTypeGroup,
|
||||
}
|
||||
groupChannel, err := th.App.CreateChannel(th.Context, groupChannel, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable = model.NewBool(false)
|
||||
})
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: groupChannel.Id}}}, groupChannel, th.BasicChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, "Wrangler is currently configured to not allow moving posts from group message channels", e.Error())
|
||||
})
|
||||
|
||||
t.Run("moving to different team with MoveThreadToAnotherTeamEnable disabled", func(t *testing.T) {
|
||||
team := &model.Team{
|
||||
Name: "testteam",
|
||||
DisplayName: "testteam",
|
||||
Type: model.TeamOpen,
|
||||
}
|
||||
|
||||
targetTeam, err := th.App.CreateTeam(th.Context, team)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, targetTeam)
|
||||
|
||||
targetChannel := &model.Channel{
|
||||
TeamId: targetTeam.Id,
|
||||
Name: "test-channel",
|
||||
Type: model.ChannelTypeOpen,
|
||||
}
|
||||
|
||||
targetChannel, err = th.App.CreateChannel(th.Context, targetChannel, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.WranglerSettings.MoveThreadToAnotherTeamEnable = model.NewBool(false)
|
||||
})
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, targetChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, "Wrangler is currently configured to not allow moving messages to different teams", e.Error())
|
||||
})
|
||||
|
||||
t.Run("moving to channel user is not a member of", func(t *testing.T) {
|
||||
targetChannel := &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: "test-channel",
|
||||
Type: model.ChannelTypePrivate,
|
||||
}
|
||||
targetChannel, err := th.App.CreateChannel(th.Context, targetChannel, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
err = th.App.RemoveUserFromChannel(th.Context, th.BasicUser.Id, th.SystemAdminUser.Id, th.BasicChannel)
|
||||
require.Nil(t, err)
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, targetChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, fmt.Sprintf("channel with ID %s doesn't exist or you are not a member", targetChannel.Id), e.Error())
|
||||
})
|
||||
|
||||
t.Run("moving thread longer than MoveThreadMaxCount", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.WranglerSettings.MoveThreadMaxCount = 1
|
||||
})
|
||||
|
||||
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}, {ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, th.BasicChannel, th.BasicUser)
|
||||
require.Error(t, e)
|
||||
require.Equal(t, "the thread is 2 posts long, but this command is configured to only move threads of up to 1 posts", e.Error())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,14 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
|
|||
props["DelayChannelAutocomplete"] = strconv.FormatBool(*c.ExperimentalSettings.DelayChannelAutocomplete)
|
||||
props["UniqueEmojiReactionLimitPerPost"] = strconv.FormatInt(int64(*c.ServiceSettings.UniqueEmojiReactionLimitPerPost), 10)
|
||||
|
||||
props["WranglerPermittedWranglerRoles"] = strings.Join(c.WranglerSettings.PermittedWranglerRoles, ",")
|
||||
props["WranglerAllowedEmailDomain"] = strings.Join(c.WranglerSettings.AllowedEmailDomain, ",")
|
||||
props["WranglerMoveThreadMaxCount"] = strconv.FormatInt(*c.WranglerSettings.MoveThreadMaxCount, 10)
|
||||
props["WranglerMoveThreadToAnotherTeamEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadToAnotherTeamEnable)
|
||||
props["WranglerMoveThreadFromPrivateChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromPrivateChannelEnable)
|
||||
props["WranglerMoveThreadFromDirectMessageChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromDirectMessageChannelEnable)
|
||||
props["WranglerMoveThreadFromGroupMessageChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromGroupMessageChannelEnable)
|
||||
|
||||
if license != nil {
|
||||
props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer)
|
||||
|
||||
|
|
|
|||
|
|
@ -2454,6 +2454,14 @@
|
|||
"other": "{{.Count}} images sent: {{.Filenames}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "api.post.move_thread.disabled.app_error",
|
||||
"translation": "Thread moving is disabled"
|
||||
},
|
||||
{
|
||||
"id": "api.post.move_thread.no_permission",
|
||||
"translation": "You do not have permission to move this thread."
|
||||
},
|
||||
{
|
||||
"id": "api.post.patch_post.can_not_update_post_in_deleted.error",
|
||||
"translation": "Can not update a post in a deleted channel."
|
||||
|
|
@ -6442,6 +6450,30 @@
|
|||
"id": "app.post.marshal.app_error",
|
||||
"translation": "Failed to marshal post."
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread.from_another_channel",
|
||||
"translation": "This thread was moved from another channel"
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread_command.channel.multiple_messages",
|
||||
"translation": "A thread with {{.NumMessages}} messages has been moved: {{.Link}}\n"
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread_command.channel.one_message",
|
||||
"translation": "A message has been moved: {{.Link}}\n"
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread_command.direct_or_group.multiple_messages",
|
||||
"translation": "A thread with {{.NumMessages}} messages has been moved to a Direct/Group Message\n"
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread_command.direct_or_group.one_message",
|
||||
"translation": "A message has been moved to a Direct/Group Message\n"
|
||||
},
|
||||
{
|
||||
"id": "app.post.move_thread_command.error",
|
||||
"translation": "Unable to remove thread"
|
||||
},
|
||||
{
|
||||
"id": "app.post.overwrite.app_error",
|
||||
"translation": "Unable to overwrite the Post."
|
||||
|
|
@ -8894,6 +8926,10 @@
|
|||
"id": "model.config.is_valid.message_export.global_relay.smtp_username.app_error",
|
||||
"translation": "Message export job GlobalRelaySettings.SmtpUsername must be set."
|
||||
},
|
||||
{
|
||||
"id": "model.config.is_valid.move_thread.domain_invalid.app_error",
|
||||
"translation": "Invalid domain for move thread settings"
|
||||
},
|
||||
{
|
||||
"id": "model.config.is_valid.password_length.app_error",
|
||||
"translation": "Minimum password length must be a whole number greater than or equal to {{.MinLength}} and less than or equal to {{.MaxLength}}."
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ const (
|
|||
TrackConfigImageProxy = "config_image_proxy"
|
||||
TrackConfigBleve = "config_bleve"
|
||||
TrackConfigExport = "config_export"
|
||||
TrackConfigWrangler = "config_wrangler"
|
||||
TrackFeatureFlags = "config_feature_flags"
|
||||
TrackConfigProducts = "products"
|
||||
TrackPermissionsGeneral = "permissions_general"
|
||||
|
|
@ -880,6 +881,16 @@ func (ts *TelemetryService) trackConfig() {
|
|||
"retention_days": *cfg.ExportSettings.RetentionDays,
|
||||
})
|
||||
|
||||
ts.SendTelemetry(TrackConfigWrangler, map[string]any{
|
||||
"permitted_wrangler_users": cfg.WranglerSettings.PermittedWranglerRoles,
|
||||
"allowed_email_domain": cfg.WranglerSettings.AllowedEmailDomain,
|
||||
"move_thread_max_count": cfg.WranglerSettings.MoveThreadMaxCount,
|
||||
"move_thread_to_another_team_enable": cfg.WranglerSettings.MoveThreadToAnotherTeamEnable,
|
||||
"move_thread_from_private_channel_enable": cfg.WranglerSettings.MoveThreadFromPrivateChannelEnable,
|
||||
"move_thread_from_direct_message_channel_enable": cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable,
|
||||
"move_thread_from_group_message_channel_enable": cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable,
|
||||
})
|
||||
|
||||
// Convert feature flags to map[string]any for sending
|
||||
flags := cfg.FeatureFlags.ToMap()
|
||||
interfaceFlags := make(map[string]any)
|
||||
|
|
|
|||
|
|
@ -4184,6 +4184,21 @@ func (c *Client4) GetPostsBefore(ctx context.Context, channelId, postId string,
|
|||
return &list, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
// MoveThread moves a thread based on provided post id, and channel id string.
|
||||
func (c *Client4) MoveThread(ctx context.Context, postId string, params *MoveThreadParams) (*Response, error) {
|
||||
js, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, NewAppError("MoveThread", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
r, err := c.DoAPIPost(ctx, c.postRoute(postId)+"/move", string(js))
|
||||
if err != nil {
|
||||
return BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BuildResponse(r), nil
|
||||
}
|
||||
|
||||
// GetPostsAroundLastUnread gets a list of posts around last unread post by a user in a channel.
|
||||
func (c *Client4) GetPostsAroundLastUnread(ctx context.Context, userId, channelId string, limitBefore, limitAfter int, collapsedThreads bool) (*PostList, *Response, error) {
|
||||
query := fmt.Sprintf("?limit_before=%v&limit_after=%v", limitBefore, limitAfter)
|
||||
|
|
|
|||
|
|
@ -3108,6 +3108,51 @@ func (s *PluginSettings) SetDefaults(ls LogSettings) {
|
|||
}
|
||||
}
|
||||
|
||||
type WranglerSettings struct {
|
||||
PermittedWranglerRoles []string
|
||||
AllowedEmailDomain []string
|
||||
MoveThreadMaxCount *int64
|
||||
MoveThreadToAnotherTeamEnable *bool
|
||||
MoveThreadFromPrivateChannelEnable *bool
|
||||
MoveThreadFromDirectMessageChannelEnable *bool
|
||||
MoveThreadFromGroupMessageChannelEnable *bool
|
||||
}
|
||||
|
||||
func (w *WranglerSettings) SetDefaults() {
|
||||
if w.PermittedWranglerRoles == nil {
|
||||
w.PermittedWranglerRoles = make([]string, 0)
|
||||
}
|
||||
if w.AllowedEmailDomain == nil {
|
||||
w.AllowedEmailDomain = make([]string, 0)
|
||||
}
|
||||
if w.MoveThreadMaxCount == nil {
|
||||
w.MoveThreadMaxCount = NewInt64(100)
|
||||
}
|
||||
if w.MoveThreadToAnotherTeamEnable == nil {
|
||||
w.MoveThreadToAnotherTeamEnable = NewBool(false)
|
||||
}
|
||||
if w.MoveThreadFromPrivateChannelEnable == nil {
|
||||
w.MoveThreadFromPrivateChannelEnable = NewBool(false)
|
||||
}
|
||||
if w.MoveThreadFromDirectMessageChannelEnable == nil {
|
||||
w.MoveThreadFromDirectMessageChannelEnable = NewBool(false)
|
||||
}
|
||||
if w.MoveThreadFromGroupMessageChannelEnable == nil {
|
||||
w.MoveThreadFromGroupMessageChannelEnable = NewBool(false)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WranglerSettings) IsValid() *AppError {
|
||||
validDomainRegex := regexp.MustCompile(`^(([a-zA-Z]{1})|([a-zA-Z]{1}[a-zA-Z]{1})|([a-zA-Z]{1}[0-9]{1})|([0-9]{1}[a-zA-Z]{1})|([a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9]))\.([a-zA-Z]{2,6}|[a-zA-Z0-9-]{2,30}\.[a-zA-Z]{2,3})$`)
|
||||
for _, domain := range w.AllowedEmailDomain {
|
||||
if !validDomainRegex.MatchString(domain) && domain != "localhost" {
|
||||
return NewAppError("Config.IsValid", "model.config.is_valid.move_thread.domain_invalid.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type GlobalRelayMessageExportSettings struct {
|
||||
CustomerType *string `access:"compliance_compliance_export"` // must be either A9, A10 or CUSTOM, dictates SMTP server url
|
||||
SMTPUsername *string `access:"compliance_compliance_export"`
|
||||
|
|
@ -3405,6 +3450,7 @@ type Config struct {
|
|||
FeatureFlags *FeatureFlags `access:"*_read" json:",omitempty"`
|
||||
ImportSettings ImportSettings // telemetry: none
|
||||
ExportSettings ExportSettings
|
||||
WranglerSettings WranglerSettings
|
||||
}
|
||||
|
||||
func (o *Config) Auditable() map[string]interface{} {
|
||||
|
|
@ -3520,6 +3566,7 @@ func (o *Config) SetDefaults() {
|
|||
}
|
||||
o.ImportSettings.SetDefaults()
|
||||
o.ExportSettings.SetDefaults()
|
||||
o.WranglerSettings.SetDefaults()
|
||||
}
|
||||
|
||||
func (o *Config) IsValid() *AppError {
|
||||
|
|
@ -3610,6 +3657,11 @@ func (o *Config) IsValid() *AppError {
|
|||
if appErr := o.ImportSettings.isValid(); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
if appErr := o.WranglerSettings.IsValid(); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,24 @@ func TestConfigOverwriteSignatureAlgorithm(t *testing.T) {
|
|||
require.Equal(t, *c1.SamlSettings.CanonicalAlgorithm, testAlgorithm)
|
||||
}
|
||||
|
||||
func TestWranglerSettingsIsValid(t *testing.T) {
|
||||
// // Test valid domains
|
||||
w := &WranglerSettings{
|
||||
AllowedEmailDomain: []string{"example.com", "subdomain.example.com"},
|
||||
}
|
||||
if err := w.IsValid(); err != nil {
|
||||
t.Errorf("Expected no error for valid domains, but got %v", err)
|
||||
}
|
||||
|
||||
// Test invalid domains
|
||||
w = &WranglerSettings{
|
||||
AllowedEmailDomain: []string{"example", "example..com", "example-.com", "-example.com", "example.com.", "example.com-"},
|
||||
}
|
||||
if err := w.IsValid(); err == nil {
|
||||
t.Errorf("Expected error for invalid domains, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigIsValidDefaultAlgorithms(t *testing.T) {
|
||||
c1 := Config{}
|
||||
c1.SetDefaults()
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ type FeatureFlags struct {
|
|||
|
||||
EnableExportDirectDownload bool
|
||||
|
||||
MoveThreadsEnabled bool
|
||||
|
||||
StreamlinedMarketplace bool
|
||||
|
||||
CloudIPFiltering bool
|
||||
|
|
@ -62,6 +64,7 @@ func (f *FeatureFlags) SetDefaults() {
|
|||
f.OnboardingTourTips = true
|
||||
f.CloudReverseTrial = false
|
||||
f.EnableExportDirectDownload = false
|
||||
f.MoveThreadsEnabled = false
|
||||
f.StreamlinedMarketplace = true
|
||||
f.CloudIPFiltering = false
|
||||
f.ConsumePostHook = false
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const (
|
|||
PostTypeChannelRestored = "system_channel_restored"
|
||||
PostTypeEphemeral = "system_ephemeral"
|
||||
PostTypeChangeChannelPrivacy = "system_change_chan_privacy"
|
||||
PostTypeWrangler = "system_wrangler"
|
||||
PostTypeGMConvertedToChannel = "system_gm_to_channel"
|
||||
PostTypeAddBotTeamsChannels = "add_bot_teams_channels"
|
||||
PostTypeSystemWarnMetricStatus = "warn_metric_status"
|
||||
|
|
@ -193,6 +194,10 @@ type GetPersistentNotificationsPostsParams struct {
|
|||
PerPage int
|
||||
}
|
||||
|
||||
type MoveThreadParams struct {
|
||||
ChannelId string `json:"channel_id"`
|
||||
}
|
||||
|
||||
type SearchParameter struct {
|
||||
Terms *string `json:"terms"`
|
||||
IsOrSearch *bool `json:"is_or_search"`
|
||||
|
|
@ -444,6 +449,7 @@ func (o *Post) IsValid(maxPostSize int) *AppError {
|
|||
PostTypeSystemWarnMetricStatus,
|
||||
PostTypeReminder,
|
||||
PostTypeMe,
|
||||
PostTypeWrangler,
|
||||
PostTypeGMConvertedToChannel:
|
||||
default:
|
||||
if !strings.HasPrefix(o.Type, PostCustomTypePrefix) {
|
||||
|
|
@ -893,3 +899,11 @@ func (o *Post) IsUrgent() bool {
|
|||
|
||||
return *postPriority.Priority == PostPriorityUrgent
|
||||
}
|
||||
|
||||
func (o *Post) CleanPost() *Post {
|
||||
o.Id = ""
|
||||
o.CreateAt = 0
|
||||
o.UpdateAt = 0
|
||||
o.EditAt = 0
|
||||
return o
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,3 +190,39 @@ func (o *PostList) IsChannelId(channelId string) bool {
|
|||
|
||||
return true
|
||||
}
|
||||
|
||||
func (o *PostList) BuildWranglerPostList() *WranglerPostList {
|
||||
wpl := &WranglerPostList{}
|
||||
|
||||
o.UniqueOrder()
|
||||
o.SortByCreateAt()
|
||||
posts := o.ToSlice()
|
||||
|
||||
if len(posts) == 0 {
|
||||
// Something was sorted wrong or an empty PostList was provided.
|
||||
return wpl
|
||||
}
|
||||
|
||||
// A separate ID key map to ensure no duplicates.
|
||||
idKeys := make(map[string]bool)
|
||||
|
||||
for i := range posts {
|
||||
p := posts[len(posts)-i-1]
|
||||
|
||||
// Add UserID to metadata if it's new.
|
||||
if _, ok := idKeys[p.UserId]; !ok {
|
||||
idKeys[p.UserId] = true
|
||||
wpl.ThreadUserIDs = append(wpl.ThreadUserIDs, p.UserId)
|
||||
}
|
||||
|
||||
wpl.FileAttachmentCount += int64(len(p.FileIds))
|
||||
|
||||
wpl.Posts = append(wpl.Posts, p)
|
||||
}
|
||||
|
||||
// Set metadata for earliest and latest posts
|
||||
wpl.EarlistPostTimestamp = wpl.RootPost().CreateAt
|
||||
wpl.LatestPostTimestamp = wpl.Posts[wpl.NumPosts()-1].CreateAt
|
||||
|
||||
return wpl
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1016,6 +1016,12 @@ type UsersWithGroupsAndCount struct {
|
|||
Count int64 `json:"total_count"`
|
||||
}
|
||||
|
||||
func (u *User) EmailDomain() string {
|
||||
at := strings.LastIndex(u.Email, "@")
|
||||
// at >= 0 holds true and this is not checked here. It holds true, because during signup we run `mail.ParseAddress(email)`
|
||||
return u.Email[at+1:]
|
||||
}
|
||||
|
||||
type UserPostStats struct {
|
||||
LastLogin int64 `json:"last_login_at,omitempty"`
|
||||
LastStatusAt *int64 `json:"last_status_at,omitempty"`
|
||||
|
|
|
|||
33
server/public/model/wrangler.go
Normal file
33
server/public/model/wrangler.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
// WranglerPostList provides a list of posts along with metadata about those
|
||||
// posts.
|
||||
type WranglerPostList struct {
|
||||
Posts []*Post
|
||||
ThreadUserIDs []string
|
||||
EarlistPostTimestamp int64
|
||||
LatestPostTimestamp int64
|
||||
FileAttachmentCount int64
|
||||
}
|
||||
|
||||
// NumPosts returns the number of posts in a post list.
|
||||
func (wpl *WranglerPostList) NumPosts() int {
|
||||
return len(wpl.Posts)
|
||||
}
|
||||
|
||||
// RootPost returns the root post in a post list.
|
||||
func (wpl *WranglerPostList) RootPost() *Post {
|
||||
if wpl.NumPosts() < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return wpl.Posts[0]
|
||||
}
|
||||
|
||||
// ContainsFileAttachments returns if the post list contains any file attachments.
|
||||
func (wpl *WranglerPostList) ContainsFileAttachments() bool {
|
||||
return wpl.FileAttachmentCount != 0
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ func InitTranslations(serverLocale, clientLocale string) error {
|
|||
defaultClientLocale = clientLocale
|
||||
|
||||
var err error
|
||||
T, err = getTranslationsBySystemLocale()
|
||||
T, err = GetTranslationsBySystemLocale()
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +151,7 @@ func GetTranslationFuncForDir(dir string) (TranslationFuncByLocal, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
func getTranslationsBySystemLocale() (TranslateFunc, error) {
|
||||
func GetTranslationsBySystemLocale() (TranslateFunc, error) {
|
||||
locale := defaultServerLocale
|
||||
if _, ok := locales[locale]; !ok {
|
||||
mlog.Warn("Failed to load system translations for selected locale, attempting to fall back to default", mlog.String("locale", locale), mlog.String("default_locale", defaultLocale))
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ exports[`components/admin_console/SchemaAdminSettings should match snapshot with
|
|||
onChange={[Function]}
|
||||
placeholder="e.g. some setting"
|
||||
setByEnv={false}
|
||||
type="input"
|
||||
type="text"
|
||||
value="fsdsdg"
|
||||
/>
|
||||
<BooleanSetting
|
||||
|
|
|
|||
|
|
@ -3199,6 +3199,91 @@ const AdminDefinition: AdminDefinitionType = {
|
|||
],
|
||||
},
|
||||
},
|
||||
wrangler: {
|
||||
url: 'site_config/wrangler',
|
||||
title: t('admin.sidebar.move_thread'),
|
||||
title_default: 'Move Thread (Beta)',
|
||||
isHidden: it.any(it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.SITE.POSTS)), it.configIsFalse('FeatureFlags', 'MoveThreadsEnabled')),
|
||||
schema: {
|
||||
id: 'WranglerSettings',
|
||||
name: t('admin.site.move_thread'),
|
||||
name_default: 'Move Thread (Beta)',
|
||||
settings: [
|
||||
{
|
||||
type: 'roles',
|
||||
multiple: true,
|
||||
key: 'WranglerSettings.PermittedWranglerRoles',
|
||||
label: t('admin.experimental.PermittedMoveThreadRoles.title'),
|
||||
label_default: 'Permitted Roles',
|
||||
help_text: t('admin.experimental.PermittedMoveThreadRoles.desc'),
|
||||
help_text_default: 'Choose who is allowed to move threads to other channels based on roles. (Other permissions below still apply).',
|
||||
help_text_markdown: false,
|
||||
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
key: 'WranglerSettings.AllowedEmailDomain',
|
||||
multiple: true,
|
||||
label: t('admin.experimental.allowedEmailDomain.title'),
|
||||
label_default: 'Allowed Email Domain',
|
||||
help_text: t('admin.experimental.allowedEmailDomain.desc'),
|
||||
help_text_default: '(Optional) When set, users must have an email ending in this domain to move threads. Multiple domains can be specified by separating them with commas.',
|
||||
help_text_markdown: false,
|
||||
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
key: 'WranglerSettings.MoveThreadMaxCount',
|
||||
label: t('admin.experimental.moveThreadMaxCount.title'),
|
||||
label_default: 'Max Thread Count Move Size',
|
||||
help_text: t('admin.experimental.moveThreadMaxCount.desc'),
|
||||
help_text_default: 'The maximum number of messages in a thread that the plugin is allowed to move. Leave empty for unlimited messages.',
|
||||
help_text_markdown: false,
|
||||
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
key: 'WranglerSettings.MoveThreadToAnotherTeamEnable',
|
||||
label: t('admin.experimental.moveThreadToAnotherTeamEnable.title'),
|
||||
label_default: 'Enable Moving Threads To Different Teams',
|
||||
help_text: t('admin.experimental.moveThreadToAnotherTeamEnable.desc'),
|
||||
help_text_default: 'Control whether Wrangler is permitted to move message threads from one team to another or not.',
|
||||
help_text_markdown: false,
|
||||
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
key: 'WranglerSettings.MoveThreadFromPrivateChannelEnable',
|
||||
label: t('admin.experimental.moveThreadFromPrivateChannelEnable.title'),
|
||||
label_default: 'Enable Moving Threads From Private Channels',
|
||||
help_text: t('admin.experimental.moveThreadFromPrivateChannelEnable.desc'),
|
||||
help_text_default: 'Control whether Wrangler is permitted to move message threads from private channels or not.',
|
||||
help_text_markdown: false,
|
||||
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
key: 'WranglerSettings.MoveThreadFromDirectMessageChannelEnable',
|
||||
label: t('admin.experimental.moveThreadFromDirectMessageChannelEnable.title'),
|
||||
label_default: 'Enable Moving Threads From Direct Message Channels',
|
||||
help_text: t('admin.experimental.moveThreadFromDirectMessageChannelEnable.desc'),
|
||||
help_text_default: 'Control whether Wrangler is permitted to move message threads from direct message channels or not.',
|
||||
help_text_markdown: false,
|
||||
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
key: 'WranglerSettings.MoveThreadFromGroupMessageChannelEnable',
|
||||
label: t('admin.experimental.moveThreadFromGroupMessageChannelEnable.title'),
|
||||
label_default: 'Enable Moving Threads From Group Message Channels',
|
||||
help_text: t('admin.experimental.moveThreadFromGroupMessageChannelEnable.desc'),
|
||||
help_text_default: 'Control whether Wrangler is permitted to move message threads from group message channels or not.',
|
||||
help_text_markdown: false,
|
||||
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
file_sharing_downloads: {
|
||||
url: 'site_config/file_sharing_downloads',
|
||||
title: t('admin.sidebar.fileSharingDownloads'),
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ exports[`components/admin_console/CustomPluginSettings should match snapshot wit
|
|||
onChange={[Function]}
|
||||
placeholder="e.g. some setting"
|
||||
setByEnv={false}
|
||||
type="input"
|
||||
type="text"
|
||||
value="setting_default"
|
||||
/>
|
||||
<BooleanSetting
|
||||
|
|
@ -356,7 +356,7 @@ exports[`components/admin_console/CustomPluginSettings should match snapshot wit
|
|||
onChange={[Function]}
|
||||
placeholder="e.g. some setting"
|
||||
setByEnv={false}
|
||||
type="input"
|
||||
type="text"
|
||||
value="fsdsdg"
|
||||
/>
|
||||
<BooleanSetting
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ State
|
|||
}
|
||||
|
||||
handleChange = (newValue: ValueType<Option>) => {
|
||||
const values = (newValue as Option[]).map((n) => {
|
||||
const values = newValue ? (newValue as Option[]).map((n) => {
|
||||
return n.value;
|
||||
});
|
||||
}) : [];
|
||||
|
||||
this.props.onChange(this.props.id, values);
|
||||
this.setState({error: false});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import Setting from './setting';
|
|||
|
||||
import './schema_admin_settings.scss';
|
||||
|
||||
const emptyList = [];
|
||||
|
||||
export default class SchemaAdminSettings extends React.PureComponent {
|
||||
static propTypes = {
|
||||
config: PropTypes.object,
|
||||
|
|
@ -75,6 +77,7 @@ export default class SchemaAdminSettings extends React.PureComponent {
|
|||
[Constants.SettingsTypes.TYPE_LANGUAGE]: this.buildLanguageSetting,
|
||||
[Constants.SettingsTypes.TYPE_JOBSTABLE]: this.buildJobsTableSetting,
|
||||
[Constants.SettingsTypes.TYPE_FILE_UPLOAD]: this.buildFileUploadSetting,
|
||||
[Constants.SettingsTypes.TYPE_ROLES]: this.buildRolesSetting,
|
||||
[Constants.SettingsTypes.TYPE_CUSTOM]: this.buildCustomSetting,
|
||||
};
|
||||
this.state = {
|
||||
|
|
@ -439,16 +442,20 @@ export default class SchemaAdminSettings extends React.PureComponent {
|
|||
};
|
||||
|
||||
buildTextSetting = (setting) => {
|
||||
let inputType = 'input';
|
||||
let inputType = 'text';
|
||||
if (setting.type === Constants.SettingsTypes.TYPE_NUMBER) {
|
||||
inputType = 'number';
|
||||
} else if (setting.type === Constants.SettingsTypes.TYPE_LONG_TEXT) {
|
||||
inputType = 'textarea';
|
||||
}
|
||||
|
||||
let value = this.state[setting.key] ?? '';
|
||||
let value = '';
|
||||
if (setting.dynamic_value) {
|
||||
value = setting.dynamic_value(value, this.props.config, this.state, this.props.license);
|
||||
} else if (setting.multiple) {
|
||||
value = this.state[setting.key] ? this.state[setting.key].join(',') : '';
|
||||
} else {
|
||||
value = this.state[setting.key] || '';
|
||||
}
|
||||
|
||||
let footer = null;
|
||||
|
|
@ -466,6 +473,7 @@ export default class SchemaAdminSettings extends React.PureComponent {
|
|||
<TextSetting
|
||||
key={this.props.schema.id + '_text_' + setting.key}
|
||||
id={setting.key}
|
||||
multiple={setting.multiple}
|
||||
type={inputType}
|
||||
label={this.renderLabel(setting)}
|
||||
helpText={this.renderHelpText(setting)}
|
||||
|
|
@ -570,6 +578,53 @@ export default class SchemaAdminSettings extends React.PureComponent {
|
|||
);
|
||||
};
|
||||
|
||||
buildRolesSetting = (setting) => {
|
||||
const {roles} = this.props;
|
||||
|
||||
const values = Object.keys(roles).map((r) => {
|
||||
return {
|
||||
value: roles[r].name,
|
||||
text: roles[r].name,
|
||||
};
|
||||
});
|
||||
|
||||
if (setting.multiple) {
|
||||
const noResultText = (
|
||||
<FormattedMessage
|
||||
id={setting.no_result}
|
||||
defaultMessage={setting.no_result_default}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<MultiSelectSetting
|
||||
key={this.props.schema.id + '_language_' + setting.key}
|
||||
id={setting.key}
|
||||
label={this.renderLabel(setting)}
|
||||
values={values}
|
||||
helpText={this.renderHelpText(setting)}
|
||||
selected={(this.state[setting.key] || emptyList)}
|
||||
disabled={this.isDisabled(setting)}
|
||||
setByEnv={this.isSetByEnv(setting.key)}
|
||||
onChange={this.handleChange}
|
||||
noResultText={noResultText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DropdownSetting
|
||||
key={this.props.schema.id + '_language_' + setting.key}
|
||||
id={setting.key}
|
||||
label={this.renderLabel(setting)}
|
||||
values={values}
|
||||
helpText={this.renderHelpText(setting)}
|
||||
value={this.state[setting.key] || values[0].value}
|
||||
disabled={this.isDisabled(setting)}
|
||||
setByEnv={this.isSetByEnv(setting.key)}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
buildLanguageSetting = (setting) => {
|
||||
const locales = I18n.getAllLanguages();
|
||||
const values = Object.keys(locales).map((l) => {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ type AdminDefinitionSettingBanner = AdminDefinitionSettingBase & {
|
|||
banner_type: 'info' | 'warning';
|
||||
}
|
||||
|
||||
type AdminDefinitionSettingRole = AdminDefinitionSettingBase & {
|
||||
type: 'roles';
|
||||
multiple?: boolean;
|
||||
help_text?: string;
|
||||
help_text_default?: string;
|
||||
help_text_markdown?: boolean;
|
||||
}
|
||||
|
||||
type AdminDefinitionSettingInput = AdminDefinitionSettingBase & {
|
||||
type: 'text' | 'bool' | 'longtext' | 'number' | 'color';
|
||||
help_text?: string | JSX.Element;
|
||||
|
|
@ -55,6 +63,7 @@ type AdminDefinitionSettingInput = AdminDefinitionSettingBase & {
|
|||
disabled_help_text_default?: string;
|
||||
disabled_help_text_markdown?: boolean;
|
||||
placeholder?: string;
|
||||
multiple?: boolean;
|
||||
placeholder_default?: string;
|
||||
validate?: Validator;
|
||||
setFromMetadataField?: string;
|
||||
|
|
@ -155,7 +164,7 @@ export type AdminDefinitionSetting = AdminDefinitionSettingCustom |
|
|||
AdminDefinitionSettingInput | AdminDefinitionSettingGenerated |
|
||||
AdminDefinitionSettingBanner | AdminDefinitionSettingDropdown |
|
||||
AdminDefinitionSettingButton | AdminDefinitionSettingFileUpload |
|
||||
AdminDefinitionSettingJobsTable | AdminDefinitionSettingLanguage;
|
||||
AdminDefinitionSettingJobsTable | AdminDefinitionSettingLanguage | AdminDefinitionSettingRole;
|
||||
|
||||
type AdminDefinitionConfigSchemaSettings = {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,114 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/dot_menu/DotMenu should match snapshot, can move 1`] = `
|
||||
Object {
|
||||
"asFragment": [Function],
|
||||
"baseElement": <body>
|
||||
<div>
|
||||
<button
|
||||
aria-controls="CENTER_dropdown_post_id_1"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="more"
|
||||
class="post-menu__item"
|
||||
data-testid="PostDotMenu-Button-post_id_1"
|
||||
id="CENTER_button_post_id_1"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
version="1.1"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</body>,
|
||||
"container": <div>
|
||||
<button
|
||||
aria-controls="CENTER_dropdown_post_id_1"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="more"
|
||||
class="post-menu__item"
|
||||
data-testid="PostDotMenu-Button-post_id_1"
|
||||
id="CENTER_button_post_id_1"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
version="1.1"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>,
|
||||
"debug": [Function],
|
||||
"findAllByAltText": [Function],
|
||||
"findAllByDisplayValue": [Function],
|
||||
"findAllByLabelText": [Function],
|
||||
"findAllByPlaceholderText": [Function],
|
||||
"findAllByRole": [Function],
|
||||
"findAllByTestId": [Function],
|
||||
"findAllByText": [Function],
|
||||
"findAllByTitle": [Function],
|
||||
"findByAltText": [Function],
|
||||
"findByDisplayValue": [Function],
|
||||
"findByLabelText": [Function],
|
||||
"findByPlaceholderText": [Function],
|
||||
"findByRole": [Function],
|
||||
"findByTestId": [Function],
|
||||
"findByText": [Function],
|
||||
"findByTitle": [Function],
|
||||
"getAllByAltText": [Function],
|
||||
"getAllByDisplayValue": [Function],
|
||||
"getAllByLabelText": [Function],
|
||||
"getAllByPlaceholderText": [Function],
|
||||
"getAllByRole": [Function],
|
||||
"getAllByTestId": [Function],
|
||||
"getAllByText": [Function],
|
||||
"getAllByTitle": [Function],
|
||||
"getByAltText": [Function],
|
||||
"getByDisplayValue": [Function],
|
||||
"getByLabelText": [Function],
|
||||
"getByPlaceholderText": [Function],
|
||||
"getByRole": [Function],
|
||||
"getByTestId": [Function],
|
||||
"getByText": [Function],
|
||||
"getByTitle": [Function],
|
||||
"queryAllByAltText": [Function],
|
||||
"queryAllByDisplayValue": [Function],
|
||||
"queryAllByLabelText": [Function],
|
||||
"queryAllByPlaceholderText": [Function],
|
||||
"queryAllByRole": [Function],
|
||||
"queryAllByTestId": [Function],
|
||||
"queryAllByText": [Function],
|
||||
"queryAllByTitle": [Function],
|
||||
"queryByAltText": [Function],
|
||||
"queryByDisplayValue": [Function],
|
||||
"queryByLabelText": [Function],
|
||||
"queryByPlaceholderText": [Function],
|
||||
"queryByRole": [Function],
|
||||
"queryByTestId": [Function],
|
||||
"queryByText": [Function],
|
||||
"queryByTitle": [Function],
|
||||
"replaceStoreState": [Function],
|
||||
"rerender": [Function],
|
||||
"unmount": [Function],
|
||||
"updateStoreState": [Function],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`components/dot_menu/DotMenu should match snapshot, canDelete 1`] = `
|
||||
Object {
|
||||
"asFragment": [Function],
|
||||
|
|
@ -109,6 +218,115 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`components/dot_menu/DotMenu should match snapshot, cannot move 1`] = `
|
||||
Object {
|
||||
"asFragment": [Function],
|
||||
"baseElement": <body>
|
||||
<div>
|
||||
<button
|
||||
aria-controls="CENTER_dropdown_post_id_1"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="more"
|
||||
class="post-menu__item"
|
||||
data-testid="PostDotMenu-Button-post_id_1"
|
||||
id="CENTER_button_post_id_1"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
version="1.1"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</body>,
|
||||
"container": <div>
|
||||
<button
|
||||
aria-controls="CENTER_dropdown_post_id_1"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="more"
|
||||
class="post-menu__item"
|
||||
data-testid="PostDotMenu-Button-post_id_1"
|
||||
id="CENTER_button_post_id_1"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
version="1.1"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>,
|
||||
"debug": [Function],
|
||||
"findAllByAltText": [Function],
|
||||
"findAllByDisplayValue": [Function],
|
||||
"findAllByLabelText": [Function],
|
||||
"findAllByPlaceholderText": [Function],
|
||||
"findAllByRole": [Function],
|
||||
"findAllByTestId": [Function],
|
||||
"findAllByText": [Function],
|
||||
"findAllByTitle": [Function],
|
||||
"findByAltText": [Function],
|
||||
"findByDisplayValue": [Function],
|
||||
"findByLabelText": [Function],
|
||||
"findByPlaceholderText": [Function],
|
||||
"findByRole": [Function],
|
||||
"findByTestId": [Function],
|
||||
"findByText": [Function],
|
||||
"findByTitle": [Function],
|
||||
"getAllByAltText": [Function],
|
||||
"getAllByDisplayValue": [Function],
|
||||
"getAllByLabelText": [Function],
|
||||
"getAllByPlaceholderText": [Function],
|
||||
"getAllByRole": [Function],
|
||||
"getAllByTestId": [Function],
|
||||
"getAllByText": [Function],
|
||||
"getAllByTitle": [Function],
|
||||
"getByAltText": [Function],
|
||||
"getByDisplayValue": [Function],
|
||||
"getByLabelText": [Function],
|
||||
"getByPlaceholderText": [Function],
|
||||
"getByRole": [Function],
|
||||
"getByTestId": [Function],
|
||||
"getByText": [Function],
|
||||
"getByTitle": [Function],
|
||||
"queryAllByAltText": [Function],
|
||||
"queryAllByDisplayValue": [Function],
|
||||
"queryAllByLabelText": [Function],
|
||||
"queryAllByPlaceholderText": [Function],
|
||||
"queryAllByRole": [Function],
|
||||
"queryAllByTestId": [Function],
|
||||
"queryAllByText": [Function],
|
||||
"queryAllByTitle": [Function],
|
||||
"queryByAltText": [Function],
|
||||
"queryByDisplayValue": [Function],
|
||||
"queryByLabelText": [Function],
|
||||
"queryByPlaceholderText": [Function],
|
||||
"queryByRole": [Function],
|
||||
"queryByTestId": [Function],
|
||||
"queryByText": [Function],
|
||||
"queryByTitle": [Function],
|
||||
"replaceStoreState": [Function],
|
||||
"rerender": [Function],
|
||||
"unmount": [Function],
|
||||
"updateStoreState": [Function],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`components/dot_menu/DotMenu should match snapshot, on Center 1`] = `
|
||||
<Menu
|
||||
menu={
|
||||
|
|
@ -301,6 +519,26 @@ exports[`components/dot_menu/DotMenu should match snapshot, on Center 1`] = `
|
|||
/>
|
||||
}
|
||||
/>
|
||||
<MenuItem
|
||||
id="move_thread_post_id_1"
|
||||
labels={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Move Thread"
|
||||
id="post_info.move_thread"
|
||||
/>
|
||||
}
|
||||
leadingElement={
|
||||
<MessageArrowRightOutlineIcon
|
||||
size={18}
|
||||
/>
|
||||
}
|
||||
onClick={[Function]}
|
||||
trailingElements={
|
||||
<ShortcutKey
|
||||
shortcutKey="W"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MenuItemSeparator />
|
||||
<MenuItem
|
||||
data-testid="permalink_post_id_1"
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ describe('components/dot_menu/DotMenu', () => {
|
|||
threadReplyCount: 0,
|
||||
userId: 'user_id_1',
|
||||
isMilitaryTime: false,
|
||||
canMove: true,
|
||||
};
|
||||
|
||||
test('should match snapshot, on Center', () => {
|
||||
|
|
@ -180,6 +181,32 @@ describe('components/dot_menu/DotMenu', () => {
|
|||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot, can move', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
canMove: true,
|
||||
};
|
||||
const wrapper = renderWithContext(
|
||||
<DotMenu {...props}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot, cannot move', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
canMove: false,
|
||||
};
|
||||
const wrapper = renderWithContext(
|
||||
<DotMenu {...props}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should show mark as unread when channel is not archived', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
EmoticonPlusOutlineIcon,
|
||||
LinkVariantIcon,
|
||||
MarkAsUnreadIcon,
|
||||
MessageArrowRightOutlineIcon,
|
||||
MessageCheckOutlineIcon,
|
||||
MessageMinusOutlineIcon,
|
||||
PencilOutlineIcon,
|
||||
|
|
@ -31,6 +32,7 @@ import Permissions from 'mattermost-redux/constants/permissions';
|
|||
import DeletePostModal from 'components/delete_post_modal';
|
||||
import ForwardPostModal from 'components/forward_post_modal';
|
||||
import * as Menu from 'components/menu';
|
||||
import MoveThreadModal from 'components/move_thread_modal';
|
||||
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
|
||||
|
||||
import {Locations, ModalIdentifiers, Constants, TELEMETRY_LABELS} from 'utils/constants';
|
||||
|
|
@ -76,6 +78,7 @@ type Props = {
|
|||
isMobileView: boolean;
|
||||
timezone?: string;
|
||||
isMilitaryTime: boolean;
|
||||
canMove: boolean;
|
||||
|
||||
actions: {
|
||||
|
||||
|
|
@ -118,6 +121,7 @@ type Props = {
|
|||
* Function to set the thread as followed/unfollowed
|
||||
*/
|
||||
setThreadFollow: (userId: string, teamId: string, threadId: string, newState: boolean) => void;
|
||||
|
||||
}; // TechDebt: Made non-mandatory while converting to typescript
|
||||
|
||||
canEdit: boolean;
|
||||
|
|
@ -251,6 +255,24 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
|
|||
trackDotMenuEvent(e, TELEMETRY_LABELS.DELETE);
|
||||
};
|
||||
|
||||
handleMoveThreadMenuItemActivated = (e: ChangeEvent): void => {
|
||||
e.preventDefault();
|
||||
if (!this.props.canMove) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackDotMenuEvent(e, TELEMETRY_LABELS.MOVE_THREAD);
|
||||
const moveThreadModalData = {
|
||||
modalId: ModalIdentifiers.MOVE_THREAD_MODAL,
|
||||
dialogType: MoveThreadModal,
|
||||
dialogProps: {
|
||||
post: this.props.post,
|
||||
},
|
||||
};
|
||||
|
||||
this.props.actions.openModal(moveThreadModalData);
|
||||
};
|
||||
|
||||
handleForwardMenuItemActivated = (e: ChangeEvent): void => {
|
||||
if (!this.canPostBeForwarded) {
|
||||
// adding this early return since only hiding the Item from the menu is not enough,
|
||||
|
|
@ -361,6 +383,12 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
|
|||
this.handleDeleteMenuItemActivated(event);
|
||||
break;
|
||||
|
||||
// move thread
|
||||
case Keyboard.isKeyPressed(event, Constants.KeyCodes.W):
|
||||
this.handleMoveThreadMenuItemActivated(event);
|
||||
this.props.handleDropdownOpened(false);
|
||||
break;
|
||||
|
||||
// pin / unpin
|
||||
case Keyboard.isKeyPressed(event, Constants.KeyCodes.P):
|
||||
forceCloseMenu();
|
||||
|
|
@ -595,6 +623,19 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
|
|||
onClick={this.handlePinMenuItemActivated}
|
||||
/>
|
||||
}
|
||||
{Boolean(!isSystemMessage && this.props.canMove) &&
|
||||
<Menu.Item
|
||||
id={`move_thread_${this.props.post.id}`}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id={'post_info.move_thread'}
|
||||
defaultMessage={'Move Thread'}
|
||||
/>}
|
||||
leadingElement={<MessageArrowRightOutlineIcon size={18}/>}
|
||||
trailingElements={<ShortcutKey shortcutKey='W'/>}
|
||||
onClick={this.handleMoveThreadMenuItemActivated}
|
||||
/>
|
||||
}
|
||||
{!isSystemMessage && (this.state.canEdit || this.state.canDelete) && <Menu.Separator/>}
|
||||
{!isSystemMessage &&
|
||||
<Menu.Item
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ describe('components/dot_menu/DotMenu returning empty ("")', () => {
|
|||
threadId: 'post_id_1',
|
||||
userId: 'user_id_1',
|
||||
isMilitaryTime: false,
|
||||
canMove: true,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ describe('components/dot_menu/DotMenu on mobile view', () => {
|
|||
threadId: 'post_id_1',
|
||||
userId: 'user_id_1',
|
||||
isMilitaryTime: false,
|
||||
canMove: true,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
markPostAsUnread,
|
||||
} from 'actions/post_actions';
|
||||
import {openModal} from 'actions/views/modals';
|
||||
import {makeCanWrangler} from 'selectors/posts';
|
||||
import {getIsMobileView} from 'selectors/views/browser';
|
||||
|
||||
import {isArchivedChannel} from 'utils/channel_utils';
|
||||
|
|
@ -58,6 +59,7 @@ type Props = {
|
|||
|
||||
function makeMapStateToProps() {
|
||||
const getThreadOrSynthetic = makeGetThreadOrSynthetic();
|
||||
const canWrangler = makeCanWrangler();
|
||||
|
||||
return function mapStateToProps(state: GlobalState, ownProps: Props) {
|
||||
const {post} = ownProps;
|
||||
|
|
@ -123,6 +125,7 @@ function makeMapStateToProps() {
|
|||
isMobileView: getIsMobileView(state),
|
||||
timezone: getCurrentTimezone(state),
|
||||
isMilitaryTime,
|
||||
canMove: canWrangler(state, channel.type, threadReplyCount),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,15 +222,14 @@ const DropdownIndicator = (props: IndicatorProps<ChannelOption>) => {
|
|||
);
|
||||
};
|
||||
|
||||
const validChannelTypes = ['O', 'P', 'D', 'G'];
|
||||
|
||||
type Props<O extends OptionTypeBase> = {
|
||||
onSelect: (channel: ValueType<O>) => void;
|
||||
currentBodyHeight: number;
|
||||
value?: O;
|
||||
validChannelTypes?: string[];
|
||||
}
|
||||
|
||||
function ForwardPostChannelSelect({onSelect, value, currentBodyHeight}: Props<ChannelOption>) {
|
||||
function ForwardPostChannelSelect({onSelect, value, currentBodyHeight, validChannelTypes = ['O', 'P', 'D', 'G']}: Props<ChannelOption>) {
|
||||
const {formatMessage} = useIntl();
|
||||
const {current: provider} = useRef<SwitchChannelProvider>(new SwitchChannelProvider());
|
||||
|
||||
|
|
|
|||
|
|
@ -141,5 +141,14 @@
|
|||
opacity: 0.64 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.post-error {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
margin-bottom: 0;
|
||||
font-size: 0.85em;
|
||||
font-weight: normal;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ const ForwardPostModal = ({onExited, post, actions}: Props) => {
|
|||
|
||||
const postPreviewFooterMessage = formatMessage({
|
||||
id: 'forward_post_modal.preview.footer_message',
|
||||
defaultMessage: 'Originally posted in ~{channelName}',
|
||||
defaultMessage: 'Originally posted in ~{channel}',
|
||||
},
|
||||
{
|
||||
channel: channel.display_name,
|
||||
|
|
|
|||
29
webapp/channels/src/components/move_thread_modal/index.ts
Normal file
29
webapp/channels/src/components/move_thread_modal/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ConnectedProps} from 'react-redux';
|
||||
import {connect} from 'react-redux';
|
||||
import type {ActionCreatorsMapObject, Dispatch} from 'redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
|
||||
import {moveThread} from 'mattermost-redux/actions/posts';
|
||||
|
||||
import {joinChannelById, switchToChannel} from 'actions/views/channel';
|
||||
|
||||
import type {ActionProps} from './move_thread_modal';
|
||||
import MoveThreadModal from './move_thread_modal';
|
||||
|
||||
export type PropsFromRedux = ConnectedProps<typeof connector>;
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators<ActionCreatorsMapObject<any>, ActionProps>({
|
||||
joinChannelById,
|
||||
switchToChannel,
|
||||
moveThread,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
const connector = connect(null, mapDispatchToProps);
|
||||
|
||||
export default connector(MoveThreadModal);
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
.move-thread {
|
||||
.modal-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
&.modal-dialog {
|
||||
margin-top: 64px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
overflow: visible;
|
||||
flex-direction: column;
|
||||
color: var(--center-channel-color);
|
||||
gap: 24px;
|
||||
|
||||
.custom-textarea,
|
||||
#move_thread_textbox {
|
||||
min-height: 40px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
#move_thread_textbox {
|
||||
overflow: auto;
|
||||
border: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.16);
|
||||
|
||||
&:focus {
|
||||
box-shadow: inset 0 0 0 2px var(--button-bg);
|
||||
}
|
||||
|
||||
&:focus:not(:focus-visible) {
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.16);
|
||||
}
|
||||
|
||||
&:focus:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--button-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__select {
|
||||
position: relative;
|
||||
|
||||
.option,
|
||||
.singleValue {
|
||||
top: 100%;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
color: var(--center-channel-color);
|
||||
gap: 12px;
|
||||
grid-auto-columns: minmax(20%, max-content);
|
||||
grid-template-columns: 24px 1fr;
|
||||
grid-template-rows: 24px;
|
||||
|
||||
svg,
|
||||
.status-wrapper,
|
||||
.status--group {
|
||||
place-self: center;
|
||||
}
|
||||
|
||||
.status--group {
|
||||
display: flex;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.12);
|
||||
}
|
||||
|
||||
.status-wrapper {
|
||||
height: 2.4rem;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
|
||||
&--text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&--description {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
}
|
||||
|
||||
.emoticon {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__team-name {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
grid-column: 3 / 4;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__post-preview {
|
||||
&--title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&--override {
|
||||
padding: 2px 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:not(.post--editing):not(.post--editing):hover {
|
||||
background-color: transparent;
|
||||
|
||||
.attachment__content.attachment__content--permalink {
|
||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.console__body .modal .GenericModal.move-thread,
|
||||
.app__body .modal .GenericModal.move-thread {
|
||||
margin-top: 64px;
|
||||
|
||||
.modal-body {
|
||||
max-height: calc(100vh - 128px - 61px - 88px);
|
||||
|
||||
.form-control {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
&#move_thread_textbox_placeholder {
|
||||
opacity: 0.64 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.post-error {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
margin-bottom: 0;
|
||||
font-size: 0.85em;
|
||||
font-weight: normal;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
import type {ValueType} from 'react-select';
|
||||
|
||||
import type {ClientError} from '@mattermost/client';
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Post, PostPreviewMetadata} from '@mattermost/types/posts';
|
||||
|
||||
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
|
||||
import type {ActionResult} from 'mattermost-redux/types/actions';
|
||||
|
||||
import type {ChannelOption} from 'components/forward_post_modal/forward_post_channel_select';
|
||||
import ChannelSelector from 'components/forward_post_modal/forward_post_channel_select';
|
||||
import NotificationBox from 'components/notification_box';
|
||||
import PostMessagePreview from 'components/post_view/post_message_preview';
|
||||
|
||||
import Constants from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import './move_thread_modal.scss';
|
||||
|
||||
export type ActionProps = {
|
||||
|
||||
// join the selected channel when necessary
|
||||
joinChannelById: (channelId: string) => Promise<ActionResult>;
|
||||
|
||||
// switch to the selected channel
|
||||
switchToChannel: (channel: Channel) => Promise<ActionResult>;
|
||||
|
||||
// action called to move the post from the original channel to a new channel
|
||||
moveThread: (postId: string, channelId: string) => Promise<ActionResult>;
|
||||
}
|
||||
|
||||
export type OwnProps = {
|
||||
|
||||
// The function called immediately after the modal is hidden
|
||||
onExited?: () => void;
|
||||
|
||||
// the post that is going to be moved
|
||||
post: Post;
|
||||
};
|
||||
|
||||
export type Props = OwnProps & {actions: ActionProps};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const getChannel = makeGetChannel();
|
||||
|
||||
// since the original post has a click handler specified we should prevent any action here
|
||||
const preventActionOnPreview = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const MoveThreadModal = ({onExited, post, actions}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const originalChannel = useSelector((state: GlobalState) => getChannel(state, {id: post.channel_id}));
|
||||
const currentTeam = useSelector(getCurrentTeam);
|
||||
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [bodyHeight, setBodyHeight] = useState<number>(0);
|
||||
const [hasError, setHasError] = useState<boolean>(false);
|
||||
const [postError, setPostError] = useState<React.ReactNode>(null);
|
||||
const [selectedChannel, setSelectedChannel] = useState<ChannelOption>();
|
||||
const [isButtonClicked, setIsButtonClicked] = useState<boolean>(false);
|
||||
|
||||
const bodyRef = useRef<HTMLDivElement>();
|
||||
|
||||
const measuredRef = useCallback((node) => {
|
||||
if (node !== null) {
|
||||
bodyRef.current = node;
|
||||
setBodyHeight(node.getBoundingClientRect().height);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onHide = useCallback(() => {
|
||||
onExited?.();
|
||||
}, [onExited]);
|
||||
|
||||
const handleChannelSelect = useCallback((channel: ValueType<ChannelOption>) => {
|
||||
if (Array.isArray(channel)) {
|
||||
setSelectedChannel(channel[0]);
|
||||
return;
|
||||
}
|
||||
setSelectedChannel(channel as ChannelOption);
|
||||
}, []);
|
||||
|
||||
const messagePreviewTitle = formatMessage({
|
||||
id: 'move_thread_modal.preview.title',
|
||||
defaultMessage: 'Message preview',
|
||||
});
|
||||
|
||||
const previewMetaData: PostPreviewMetadata = useMemo(() => ({
|
||||
post,
|
||||
post_id: post.id,
|
||||
team_name: currentTeam.name,
|
||||
channel_display_name: originalChannel.display_name,
|
||||
channel_type: originalChannel.type,
|
||||
channel_id: originalChannel.id,
|
||||
}), [post, currentTeam.name, originalChannel.display_name, originalChannel.type, originalChannel.id]);
|
||||
|
||||
const notificationText = formatMessage({
|
||||
id: 'move_thread_modal.notification.dm_or_gm',
|
||||
defaultMessage: 'Moving this thread changes who has access',
|
||||
});
|
||||
|
||||
const notification = (
|
||||
<NotificationBox
|
||||
variant={'info'}
|
||||
text={notificationText}
|
||||
id={'move_thread'}
|
||||
/>
|
||||
);
|
||||
|
||||
const handlePostError = useCallback((error: ClientError) => {
|
||||
setIsButtonClicked(false);
|
||||
setPostError(error.message);
|
||||
setHasError(true);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setHasError(false);
|
||||
timeoutRef.current = null;
|
||||
}, Constants.ANIMATION_TIMEOUT);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setIsButtonClicked(true);
|
||||
if (!selectedChannel) {
|
||||
setIsButtonClicked(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = selectedChannel.details;
|
||||
|
||||
let result = await actions.moveThread(post.root_id || post.id, channel.id);
|
||||
|
||||
if (result.error) {
|
||||
handlePostError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
channel.type === Constants.MENTION_MORE_CHANNELS &&
|
||||
channel.type === Constants.OPEN_CHANNEL
|
||||
) {
|
||||
result = await actions.joinChannelById(channel.id);
|
||||
|
||||
if (result.error) {
|
||||
handlePostError(result.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
result = await actions.switchToChannel(channel);
|
||||
|
||||
if (result.error) {
|
||||
handlePostError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
onHide();
|
||||
}, [selectedChannel, post, actions, handlePostError, onHide]);
|
||||
|
||||
const postPreviewFooterMessage = formatMessage({
|
||||
id: 'move_thread_modal.preview.footer_message',
|
||||
defaultMessage: 'Originally posted in ~{channelName}',
|
||||
},
|
||||
{
|
||||
channelName: originalChannel.display_name,
|
||||
});
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
className='a11y__modal forward-post move-thread'
|
||||
id='move-thread-modal'
|
||||
show={true}
|
||||
autoCloseOnConfirmButton={false}
|
||||
compassDesign={true}
|
||||
modalHeaderText={formatMessage({
|
||||
id: 'move_thread_modal.title',
|
||||
defaultMessage: 'Move thread',
|
||||
})}
|
||||
confirmButtonText={formatMessage({
|
||||
id: 'move_thread_modal.button.forward',
|
||||
defaultMessage: 'Move',
|
||||
})}
|
||||
cancelButtonText={formatMessage({
|
||||
id: 'move_thread_modal.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
})}
|
||||
cancelButtonClassName={'MoveThreadModal__cancel-button'}
|
||||
isConfirmDisabled={isButtonClicked}
|
||||
handleConfirm={handleSubmit}
|
||||
handleEnterKeyPress={handleSubmit}
|
||||
handleCancel={onHide}
|
||||
onExited={onHide}
|
||||
>
|
||||
<div
|
||||
className={'move-thread__body'}
|
||||
ref={measuredRef}
|
||||
>
|
||||
{notification}
|
||||
<ChannelSelector
|
||||
onSelect={handleChannelSelect}
|
||||
value={selectedChannel}
|
||||
currentBodyHeight={bodyHeight}
|
||||
validChannelTypes={['O', 'P']}
|
||||
/>
|
||||
<div className={'move-thread__post-preview'}>
|
||||
<span className={'move-thread__post-preview--title'}>
|
||||
{messagePreviewTitle}
|
||||
</span>
|
||||
<div
|
||||
className='post move-thread__post-preview--override'
|
||||
onClick={preventActionOnPreview}
|
||||
>
|
||||
<PostMessagePreview
|
||||
metadata={previewMetaData}
|
||||
handleFileDropdownOpened={noop}
|
||||
preventClickAction={true}
|
||||
previewFooterMessage={postPreviewFooterMessage}
|
||||
/>
|
||||
</div>
|
||||
{postError && (
|
||||
<label
|
||||
className={classNames('post-error', {
|
||||
'animation--highlight': hasError,
|
||||
})}
|
||||
>
|
||||
{postError}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoveThreadModal;
|
||||
|
|
@ -12,7 +12,7 @@ import Markdown from 'components/markdown';
|
|||
|
||||
import type {TextFormattingOptions} from 'utils/text_formatting';
|
||||
|
||||
import {renderReminderSystemBotMessage, renderSystemMessage} from './system_message_helpers';
|
||||
import {renderReminderSystemBotMessage, renderSystemMessage, renderWranglerSystemMessage} from './system_message_helpers';
|
||||
|
||||
import {type PropsFromRedux} from './index';
|
||||
|
||||
|
|
@ -82,6 +82,11 @@ export default class PostMarkdown extends React.PureComponent<Props> {
|
|||
return <div>{renderedSystemBotMessage}</div>;
|
||||
}
|
||||
|
||||
if (this.props.post && this.props.post.type === Posts.POST_TYPES.WRANGLER) {
|
||||
const renderedWranglerMessage = renderWranglerSystemMessage(this.props.post);
|
||||
return <div>{renderedWranglerMessage}</div>;
|
||||
}
|
||||
|
||||
// Proxy images if we have an image proxy and the server hasn't already rewritten the this.props.post's image URLs.
|
||||
const proxyImages = !this.props.post || !this.props.post.message_source || this.props.post.message === this.props.post.message_source;
|
||||
const channelNamesMap = this.props.post && this.props.post.props && this.props.post.props.channel_mentions;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import CombinedSystemMessage from 'components/post_view/combined_system_message'
|
|||
import GMConversionMessage from 'components/post_view/gm_conversion_message/gm_conversion_message';
|
||||
import PostAddChannelMember from 'components/post_view/post_add_channel_member';
|
||||
|
||||
import {t} from 'utils/i18n';
|
||||
import type {TextFormattingOptions} from 'utils/text_formatting';
|
||||
import {getSiteURL} from 'utils/url';
|
||||
|
||||
|
|
@ -489,3 +490,29 @@ export function renderReminderSystemBotMessage(post: Post, currentTeam: Team): R
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
t('app.post.move_thread_command.direct_or_group.multiple_messages');
|
||||
t('app.post.move_thread_command.direct_or_group.one_message');
|
||||
t('app.post.move_thread_command.channel.multiple_messages');
|
||||
t('app.post.move_thread_command.channel.one_message');
|
||||
t('app.post.move_thread.from_another_channel');
|
||||
export function renderWranglerSystemMessage(post: Post): ReactNode {
|
||||
let values = {} as any;
|
||||
const id = post.props.TranslationID;
|
||||
if (post.props && post.props.MovedThreadPermalink) {
|
||||
values = {
|
||||
Link: post.props.MovedThreadPermalink,
|
||||
};
|
||||
if (post.props.NumMessages > 1) {
|
||||
values.NumMessages = post.props.NumMessages;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<FormattedMessage
|
||||
id={id}
|
||||
defaultMessage={post.message}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ export type Props = {
|
|||
label: ReactNode;
|
||||
labelClassName?: string;
|
||||
placeholder?: string;
|
||||
value: string | number | string[];
|
||||
helpText?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
value: string | number;
|
||||
inputClassName?: string;
|
||||
maxLength?: number;
|
||||
resizable?: boolean;
|
||||
|
|
@ -26,6 +26,7 @@ export type Props = {
|
|||
// This is a custom prop that is not part of the HTML input element type
|
||||
type?: InputTypes;
|
||||
autoFocus?: boolean;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
function TextSetting(props: Props) {
|
||||
|
|
@ -34,6 +35,12 @@ function TextSetting(props: Props) {
|
|||
function handleChange(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
if (props.type === 'number') {
|
||||
props.onChange(props.id, parseInt(event.target.value, 10));
|
||||
} else if (props.type === 'text' && props.multiple) {
|
||||
if (event.target.value === '') {
|
||||
props.onChange(props.id, []);
|
||||
} else {
|
||||
props.onChange(props.id, event.target.value.split(','));
|
||||
}
|
||||
} else {
|
||||
props.onChange(props.id, event.target.value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -887,6 +887,8 @@
|
|||
"admin.environment.webServer": "Web Server",
|
||||
"admin.experimental.allowCustomThemes.desc": "Enables the **Display > Theme > Custom Theme** section in Settings.",
|
||||
"admin.experimental.allowCustomThemes.title": "Allow Custom Themes:",
|
||||
"admin.experimental.allowedEmailDomain.desc": "(Optional) When set, users must have an email ending in this domain to move threads. Multiple domains can be specified by separating them with commas.",
|
||||
"admin.experimental.allowedEmailDomain.title": "Allowed Email Domain",
|
||||
"admin.experimental.clientSideCertCheck.desc": "When **primary**, after the client side certificate is verified, user’s email is retrieved from the certificate and is used to log in without a password. When **secondary**, after the client side certificate is verified, user’s email is retrieved from the certificate and matched against the one supplied by the user. If they match, the user logs in with regular email/password credentials.",
|
||||
"admin.experimental.clientSideCertCheck.title": "Client-Side Certification Login Method:",
|
||||
"admin.experimental.clientSideCertEnable.desc": "Enables client-side certification for your Mattermost server. See <link>documentation</link> to learn more.",
|
||||
|
|
@ -954,6 +956,18 @@
|
|||
"admin.experimental.linkMetadataTimeoutMilliseconds.desc": "The number of milliseconds to wait for metadata from a third-party link. Used with Post Metadata.",
|
||||
"admin.experimental.linkMetadataTimeoutMilliseconds.example": "E.g.: \"5000\"",
|
||||
"admin.experimental.linkMetadataTimeoutMilliseconds.title": "Link Metadata Timeout:",
|
||||
"admin.experimental.moveThreadFromDirectMessageChannelEnable.desc": "Control whether move thread is permitted to move message threads from direct message channels or not.",
|
||||
"admin.experimental.moveThreadFromDirectMessageChannelEnable.title": "Enable Moving Threads From Direct Message Channels",
|
||||
"admin.experimental.moveThreadFromGroupMessageChannelEnable.desc": "Control whether move thread is permitted to move message threads from group message channels or not.",
|
||||
"admin.experimental.moveThreadFromGroupMessageChannelEnable.title": "Enable Moving Threads From Group Message Channels",
|
||||
"admin.experimental.moveThreadFromPrivateChannelEnable.desc": "Control whether move thread is permitted to move message threads from private channels or not.",
|
||||
"admin.experimental.moveThreadFromPrivateChannelEnable.title": "Enable Moving Threads From Private Channels",
|
||||
"admin.experimental.moveThreadMaxCount.desc": "The maximum number of messages in a thread that the plugin is allowed to move. Leave empty for unlimited messages.",
|
||||
"admin.experimental.moveThreadMaxCount.title": "Max Thread Count Move Size",
|
||||
"admin.experimental.moveThreadToAnotherTeamEnable.desc": "Control whether move thread is permitted to move message threads from one team to another or not.",
|
||||
"admin.experimental.moveThreadToAnotherTeamEnable.title": "Enable Moving Threads To Different Teams",
|
||||
"admin.experimental.PermittedMoveThreadRoles.desc": "Choose who is allowed to move threads to other channels based on roles. (Other permissions below still apply).",
|
||||
"admin.experimental.PermittedMoveThreadRoles.title": "Permitted Roles",
|
||||
"admin.experimental.samlSettingsLoginButtonBorderColor.desc": "Specify the color of the SAML login button border for white labeling purposes. Use a hex code with a #-sign before the code. This setting only applies to the mobile apps.",
|
||||
"admin.experimental.samlSettingsLoginButtonBorderColor.title": "SAML login Button Border Color:",
|
||||
"admin.experimental.samlSettingsLoginButtonColor.desc": "Specify the color of the SAML login button for white labeling purposes. Use a hex code with a #-sign before the code. This setting only applies to the mobile apps.",
|
||||
|
|
@ -2335,6 +2349,7 @@
|
|||
"admin.sidebar.logs": "Server Logs",
|
||||
"admin.sidebar.metrics": "Performance Monitoring",
|
||||
"admin.sidebar.mfa": "MFA",
|
||||
"admin.sidebar.move_thread": "Move thread (Beta)",
|
||||
"admin.sidebar.notices": "Notices",
|
||||
"admin.sidebar.notifications": "Notifications",
|
||||
"admin.sidebar.oauth": "OAuth 2.0",
|
||||
|
|
@ -2370,6 +2385,7 @@
|
|||
"admin.site.emoji": "Emoji",
|
||||
"admin.site.fileSharingDownloads": "File Sharing and Downloads",
|
||||
"admin.site.localization": "Localization",
|
||||
"admin.site.move_thread": "Move thread",
|
||||
"admin.site.notices": "Notices",
|
||||
"admin.site.posts": "Posts",
|
||||
"admin.site.public_links": "Public Links",
|
||||
|
|
@ -2742,6 +2758,11 @@
|
|||
"app.channel.post_update_channel_purpose_message.removed": "{username} removed the channel purpose (was: {old})",
|
||||
"app.channel.post_update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {old} to: {new}",
|
||||
"app.channel.post_update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {new}",
|
||||
"app.post.move_thread_command.channel.multiple_messages": "A thread with {{.NumMessages}} messages has been moved: {{.Link}}\n",
|
||||
"app.post.move_thread_command.channel.one_message": "A message has been moved: {{.Link}}\n",
|
||||
"app.post.move_thread_command.direct_or_group.multiple_messages": "A thread with {{.NumMessages}} messages has been moved to a Direct/Group Message\n",
|
||||
"app.post.move_thread_command.direct_or_group.one_message": "A message has been moved to a Direct/Group Message\n",
|
||||
"app.post.move_thread.from_another_channel": "This thread was moved from another channel",
|
||||
"apps.error": "Error: {error}",
|
||||
"apps.error.command.field_missing": "Required fields missing: `{fieldName}`.",
|
||||
"apps.error.command.same_channel": "Channel repeated for field `{fieldName}`: `{option}`.",
|
||||
|
|
@ -4131,6 +4152,12 @@
|
|||
"more_direct_channels.new_convo_note.full": "You've reached the maximum number of people for this conversation. Consider creating a private channel instead.",
|
||||
"more_direct_channels.title": "Direct Messages",
|
||||
"more.details": "More details",
|
||||
"move_thread_modal.button.cancel": "Cancel",
|
||||
"move_thread_modal.button.forward": "Move",
|
||||
"move_thread_modal.notification.dm_or_gm": "Moving this thread changes who has access",
|
||||
"move_thread_modal.preview.footer_message": "Originally posted in ~{channelName}",
|
||||
"move_thread_modal.preview.title": "Message preview",
|
||||
"move_thread_modal.title": "Move thread",
|
||||
"msg_typing.areTyping": "{users} and {last} are typing...",
|
||||
"msg_typing.isTyping": "{user} is typing...",
|
||||
"multiselect.add": "Add",
|
||||
|
|
@ -4428,6 +4455,7 @@
|
|||
"post_info.message.show_more": "Show more",
|
||||
"post_info.message.visible": "(Only visible to you)",
|
||||
"post_info.message.visible.compact": " (Only visible to you)",
|
||||
"post_info.move_thread": "Move Thread",
|
||||
"post_info.permalink": "Copy Link",
|
||||
"post_info.pin": "Pin to Channel",
|
||||
"post_info.post_reminder.menu": "Remind",
|
||||
|
|
|
|||
|
|
@ -58,4 +58,7 @@ export default keyMirror({
|
|||
CREATE_ACK_POST_SUCCESS: null,
|
||||
|
||||
DELETE_ACK_POST_SUCCESS: null,
|
||||
|
||||
MOVE_POST_SUCCESS: null,
|
||||
MOVE_POST_FAILURE: null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1260,6 +1260,23 @@ export function selectPost(postId: string) {
|
|||
};
|
||||
}
|
||||
|
||||
export function moveThread(postId: string, channelId: string) {
|
||||
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
|
||||
try {
|
||||
await Client4.moveThread(postId, channelId);
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(error, dispatch, getState);
|
||||
dispatch({type: PostTypes.MOVE_POST_FAILURE, error});
|
||||
dispatch(logError(error));
|
||||
return {error};
|
||||
}
|
||||
|
||||
dispatch({type: PostTypes.MOVE_POST_SUCCESS});
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function selectFocusedPostId(postId: string) {
|
||||
return {
|
||||
type: PostTypes.RECEIVED_FOCUSED_POST,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export const PostTypes = {
|
|||
ADD_BOT_TEAMS_CHANNELS: 'add_bot_teams_channels' as PostType,
|
||||
SYSTEM_WARN_METRIC_STATUS: 'warn_metric_status' as PostType,
|
||||
REMINDER: 'reminder' as PostType,
|
||||
WRANGLER: 'system_wrangler' as PostType,
|
||||
GM_CONVERTED_TO_CHANNEL: 'system_gm_to_channel' as PostType,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -276,6 +276,10 @@ export function cloudReverseTrial(state: GlobalState): boolean {
|
|||
return getFeatureFlagValue(state, 'CloudReverseTrial') === 'true';
|
||||
}
|
||||
|
||||
export function moveThreadsEnabled(state: GlobalState): boolean {
|
||||
return getFeatureFlagValue(state, 'MoveThreadsEnabled') === 'true';
|
||||
}
|
||||
|
||||
export function streamlinedMarketplaceEnabled(state: GlobalState): boolean {
|
||||
return getFeatureFlagValue(state, 'StreamlinedMarketplace') === 'true';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {ClientConfig} from '@mattermost/types/config';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {createSelector} from 'mattermost-redux/selectors/create_selector';
|
||||
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getPost} from 'mattermost-redux/selectors/entities/posts';
|
||||
import {moveThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {arePreviewsCollapsed} from 'selectors/preferences';
|
||||
|
|
@ -52,3 +58,69 @@ export function isInlineImageVisible(state: GlobalState, postId: string, imageKe
|
|||
|
||||
return getGlobalItem(state, StoragePrefixes.INLINE_IMAGE_VISIBLE + currentUserId + '_' + postId + '_' + imageKey, !imageCollapsed);
|
||||
}
|
||||
|
||||
export function makeCanWrangler() {
|
||||
return createSelector(
|
||||
'makeCanWrangler',
|
||||
getConfig,
|
||||
getCurrentUser,
|
||||
moveThreadsEnabled,
|
||||
(_state: GlobalState, channelType: Channel['type']) => channelType,
|
||||
(_state: GlobalState, _channelType: Channel['type'], replyCount: number) => replyCount,
|
||||
(config: Partial<ClientConfig>, user: UserProfile, enabled: boolean, channelType: Channel['type'], replyCount: number) => {
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
const {
|
||||
WranglerPermittedWranglerRoles,
|
||||
WranglerAllowedEmailDomain,
|
||||
WranglerMoveThreadMaxCount,
|
||||
WranglerMoveThreadFromPrivateChannelEnable,
|
||||
WranglerMoveThreadFromDirectMessageChannelEnable,
|
||||
WranglerMoveThreadFromGroupMessageChannelEnable,
|
||||
} = config;
|
||||
|
||||
let permittedUsers: string[] = [];
|
||||
if (WranglerPermittedWranglerRoles && WranglerPermittedWranglerRoles !== '') {
|
||||
permittedUsers = WranglerPermittedWranglerRoles?.split(',');
|
||||
}
|
||||
|
||||
let allowedEmailDomains: string[] = [];
|
||||
if (WranglerAllowedEmailDomain && WranglerAllowedEmailDomain !== '') {
|
||||
allowedEmailDomains = WranglerAllowedEmailDomain?.split(',') || [];
|
||||
}
|
||||
|
||||
if (permittedUsers.length > 0 && !user.roles.includes('system_admin')) {
|
||||
const roles = user.roles.split(' ');
|
||||
const hasRole = roles.some((role) => permittedUsers.includes(role));
|
||||
if (!hasRole) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedEmailDomains?.length > 0) {
|
||||
if (!user.email || !allowedEmailDomains.includes(user.email.split('@')[1])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (Number(WranglerMoveThreadMaxCount) < replyCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (channelType === 'P' && WranglerMoveThreadFromPrivateChannelEnable === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (channelType === 'D' && WranglerMoveThreadFromDirectMessageChannelEnable === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (channelType === 'G' && WranglerMoveThreadFromGroupMessageChannelEnable === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const SettingsTypes = {
|
|||
TYPE_JOBSTABLE: 'jobstable',
|
||||
TYPE_FILE_UPLOAD: 'fileupload',
|
||||
TYPE_CUSTOM: 'custom',
|
||||
TYPE_ROLES: 'roles',
|
||||
};
|
||||
|
||||
export const InviteTypes = {
|
||||
|
|
@ -446,6 +447,7 @@ export const ModalIdentifiers = {
|
|||
SELF_HOSTED_EXPANSION: 'self_hosted_expansion',
|
||||
START_TRIAL_FORM_MODAL: 'start_trial_form_modal',
|
||||
START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result',
|
||||
MOVE_THREAD_MODAL: 'move_thread_modal',
|
||||
CONVERT_GM_TO_CHANNEL: 'convert_gm_to_channel',
|
||||
IP_FILTERING_ADD_EDIT_MODAL: 'ip_filtering_add_edit_modal',
|
||||
IP_FILTERING_DELETE_CONFIRMATION_MODAL: 'ip_filtering_delete_confirmation_modal',
|
||||
|
|
@ -769,6 +771,7 @@ export const TELEMETRY_LABELS = {
|
|||
REPLY: 'reply',
|
||||
UNREAD: 'unread',
|
||||
FORWARD: 'forward',
|
||||
MOVE_THREAD: 'move_thread',
|
||||
};
|
||||
|
||||
export const PostTypes = {
|
||||
|
|
@ -795,6 +798,7 @@ export const PostTypes = {
|
|||
REMOVE_LINK_PREVIEW: 'remove_link_preview',
|
||||
ME: 'me',
|
||||
REMINDER: 'reminder',
|
||||
WRANGLER: 'system_wrangler',
|
||||
CUSTOM_CALLS: 'custom_calls',
|
||||
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1059,6 +1059,14 @@ export default class Client4 {
|
|||
);
|
||||
}
|
||||
|
||||
moveThread = (postId: string, channelId: string) => {
|
||||
const url = this.getPostRoute(postId) + '/move';
|
||||
return this.doFetch<StatusOK>(
|
||||
url,
|
||||
{method: 'post', body: JSON.stringify({channel_id: channelId})},
|
||||
);
|
||||
}
|
||||
|
||||
switchEmailToOAuth = (service: string, email: string, password: string, mfaCode = '') => {
|
||||
this.trackEvent('api', 'api_users_email_to_oauth');
|
||||
|
||||
|
|
|
|||
|
|
@ -199,6 +199,12 @@ export type ClientConfig = {
|
|||
PersistentNotificationIntervalMinutes: string;
|
||||
AllowPersistentNotificationsForGuests: string;
|
||||
DelayChannelAutocomplete: 'true' | 'false';
|
||||
WranglerPermittedWranglerRoles: string;
|
||||
WranglerAllowedEmailDomain: string;
|
||||
WranglerMoveThreadMaxCount: string;
|
||||
WranglerMoveThreadFromPrivateChannelEnable: string;
|
||||
WranglerMoveThreadFromDirectMessageChannelEnable: string;
|
||||
WranglerMoveThreadFromGroupMessageChannelEnable: string;
|
||||
ServiceEnvironment: string;
|
||||
UniqueEmojiReactionLimitPerPost: string;
|
||||
};
|
||||
|
|
@ -485,6 +491,16 @@ export type PasswordSettings = {
|
|||
EnableForgotLink: boolean;
|
||||
};
|
||||
|
||||
export type WranglerSettings = {
|
||||
PermittedWranglerRoles: string[];
|
||||
AllowedEmailDomain: string[];
|
||||
MoveThreadMaxCount: number;
|
||||
MoveThreadToAnotherTeamEnable: boolean;
|
||||
MoveThreadFromPrivateChannelEnable: boolean;
|
||||
MoveThreadFromDirectMessageChannelEnable: boolean;
|
||||
MoveThreadFromGroupMessageChannelEnable: boolean;
|
||||
};
|
||||
|
||||
export type FileSettings = {
|
||||
EnableFileAttachments: boolean;
|
||||
EnableMobileUpload: boolean;
|
||||
|
|
@ -947,6 +963,7 @@ export type AdminConfig = {
|
|||
FeatureFlags: FeatureFlags;
|
||||
ImportSettings: ImportSettings;
|
||||
ExportSettings: ExportSettings;
|
||||
WranglerSettings: WranglerSettings;
|
||||
};
|
||||
|
||||
export type ReplicaLagSetting = {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export type PostType = 'system_add_remove' |
|
|||
'system_fake_parent_deleted' |
|
||||
'system_generic' |
|
||||
'reminder' |
|
||||
'system_wrangler' |
|
||||
'';
|
||||
|
||||
export type PostEmbedType = 'image' | 'link' | 'message_attachment' | 'opengraph' | 'permalink';
|
||||
|
|
|
|||
Loading…
Reference in a new issue