diff --git a/e2e-tests/cypress/tests/integration/channels/channel/convert_group_message_to_private_spec.js b/e2e-tests/cypress/tests/integration/channels/channel/convert_group_message_to_private_spec.js new file mode 100644 index 0000000000..111853edce --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/channel/convert_group_message_to_private_spec.js @@ -0,0 +1,164 @@ +// 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 @channel + +describe('Group Message Conversion To Private Channel', () => { + let testTeam1; + let testTeam2; + + let testUser1; + let testUser2; + let testUser3; + + let gm; + + before(() => { + // we need two teams, and a set of users belonging to both teams + cy.apiInitSetup().then(({team, user}) => { + testTeam1 = team; + testUser1 = user; + + cy.apiCreateTeam('gmconversionteam2', 'GM Conversion Team 2').then(({team: team2}) => { + testTeam2 = team2; + + cy.apiCreateUser({prefix: 'other'}).then(({user: user2}) => { + testUser2 = user2; + + cy.apiCreateUser({prefix: 'other'}).then(({user: user3}) => { + testUser3 = user3; + + const teamMembers1 = [testUser1, testUser2, testUser3].map((u) => ({team_id: testTeam1.id, user_id: u.id})); + cy.apiAddUsersToTeam(testTeam1.id, teamMembers1).then(() => { + const teamMembers2 = [testUser1, testUser2, testUser3].map((u) => ({team_id: testTeam2.id, user_id: u.id})); + cy.apiAddUsersToTeam(testTeam2.id, teamMembers2).then(() => { + cy.apiCreateGroupChannel([testUser1.id, testUser2.id, testUser3.id]).then(({channel}) => { + gm = channel; + }); + }); + }); + }); + }); + }); + }); + }); + + it('GM conversion', () => { + // Open the GM + cy.visit(`/${testTeam1.name}/messages/${gm.name}`); + + // Open the GM conversion dialog + cy.get('#channelHeaderDropdownButton').click(); + cy.findByText('Convert to Private Channel').click(); + + // the dialog has a animation, so we're waiting for it to finish + // by waiting for the (currently disabled) confirm button to appear + cy.get('.GenericModal__button.delete.disabled').wait(2000); + + // Open the team dropdown and select a team + cy.findByText('Select Team').click(); + cy.findByText(testTeam2.display_name).click(); + + // Enter the new channel name and confirm + cy.findByPlaceholderText('Channel name').type('Converted Channel'); + cy.get('.GenericModal__button.delete').click(); + + // verify the channel is in a team, as indicated by the presence + // of `/channels/` in the URL us `/messages/` in case of GM + cy.url().should('contain', `/${testTeam2.name}/channels/converted-channel`); + + // verify the channel created is a private channel by verifying + // the presence of the lock icon next to the channel name in LHS + cy.get('.SidebarChannel:contains("Converted Channel")').get('.icon.icon-lock-outline').should('be.visible'); + }); + + it('When users belong to only one common team', () => { + let testUser4; + let gm2; + + cy.apiCreateUser({prefix: 'other'}).then(({user}) => { + testUser4 = user; + + const teamMembers = [testUser1, testUser2, testUser3, testUser4].map((u) => ({ + team_id: testTeam2.id, + user_id: u.id, + })); + + cy.apiAddUsersToTeam(testTeam2.id, teamMembers).then(() => { + cy.apiCreateGroupChannel([testUser1.id, testUser2.id, testUser3.id, testUser4.id]).then(({channel}) => { + gm2 = channel; + + // Open the GM + cy.visit(`/${testTeam1.name}/messages/${gm2.name}`); + cy.get('#channelHeaderDropdownButton').click(); + cy.findByText('Convert to Private Channel').click(); + cy.get('.GenericModal__button.delete.disabled').wait(2000); + + cy.contains('Select Team').should('not.exist'); + + cy.findByPlaceholderText('Channel name').type('Converted Channel 2'); + cy.get('.GenericModal__button.delete').click(); + + cy.url().should('contain', `/${testTeam2.name}/channels/converted-channel-2`); + cy.get('.SidebarChannel:contains("Converted Channel 2")').get('.icon.icon-lock-outline').should('be.visible'); + }); + }); + }); + }); + + it('When users have no common team', () => { + let testUser5; + let gm3; + let testTeam3; + + cy.apiCreateUser({prefix: 'other'}).then(({user}) => { + testUser5 = user; + + cy.apiCreateTeam('gmconversionteam3', 'GM Conversion Team 3').then(({team}) => { + testTeam3 = team; + console.log(testTeam3); + + const teamMembers = [{ + team_id: testTeam3.id, + user_id: testUser5.id, + }]; + + cy.apiAddUsersToTeam(testTeam3.id, teamMembers).then(() => { + cy.apiCreateGroupChannel([testUser1.id, testUser2.id, testUser3.id, testUser5.id]).then(({channel}) => { + gm3 = channel; + + // Open the GM + cy.visit(`/${testTeam1.name}/messages/${gm3.name}`); + cy.get('#channelHeaderDropdownButton').click(); + cy.findByText('Convert to Private Channel').click(); + cy.findByText('Unable to convert to a channel because group members are part of different teams').wait(2000); + }); + }); + }); + }); + }); + + it('websocket event should update channel category correctly when in channel', () => { + cy.apiCreateGroupChannel([testUser1.id, testUser2.id, testUser3.id]).then(({channel}) => { + gm = channel; + + console.log(gm.name); + + // Open the GM + cy.visit(`/${testTeam1.name}/messages/${gm.name}`); + + // convert via API call + const timestamp = Date.now(); + cy.apiConvertGMToPrivateChannel(gm.id, testTeam2.id, `Channel ${timestamp}`, `c-${timestamp}`).then(() => { + cy.url().should('contain', `${testTeam2.name}/channels/c-${timestamp}`); + }); + }); + }); +}); diff --git a/e2e-tests/cypress/tests/support/api/channel.d.ts b/e2e-tests/cypress/tests/support/api/channel.d.ts index 6affd09b3b..a137ab31d4 100644 --- a/e2e-tests/cypress/tests/support/api/channel.d.ts +++ b/e2e-tests/cypress/tests/support/api/channel.d.ts @@ -212,5 +212,14 @@ declare namespace Cypress { * }); */ apiCreateArchivedChannel(name: string, displayName: string, type: string, teamId: string, messages?: string[], user?: UserProfile): Chainable; + + /** + * Command to convert a GM to a private channel + * @param {string} channelId - channel id of GM to be converted + * @param {string} teamId - id of team to move the converted private channel to + * @param {string} displayName - display name of converted channel + * @param {string} name - name of converted channel + */ + apiConvertGMToPrivateChannel(channelId: string, teamId: string, displayName: string, name: string): Chainable; } } diff --git a/e2e-tests/cypress/tests/support/api/channel.js b/e2e-tests/cypress/tests/support/api/channel.js index ef5d39631b..37fcc24e17 100644 --- a/e2e-tests/cypress/tests/support/api/channel.js +++ b/e2e-tests/cypress/tests/support/api/channel.js @@ -186,3 +186,19 @@ Cypress.Commands.add('apiCreateArchivedChannel', (name, displayName, type = 'O', return cy.wrap(channel); }); }); + +Cypress.Commands.add('apiConvertGMToPrivateChannel', (channelId, teamId, displayName, name) => { + const body = { + channel_id: channelId, + team_id: teamId, + display_name: displayName, + name, + }; + + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/channels/${channelId}/convert_to_channel?team_id=${teamId}`, + method: 'POST', + body, + }); +}); diff --git a/webapp/channels/src/components/dropdown_input.tsx b/webapp/channels/src/components/dropdown_input.tsx index 7c6617c12a..77b28f4314 100644 --- a/webapp/channels/src/components/dropdown_input.tsx +++ b/webapp/channels/src/components/dropdown_input.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; -import React, {useRef, useState} from 'react'; +import React, {useCallback, useRef, useState} from 'react'; import type {CSSProperties} from 'react'; import {useIntl} from 'react-intl'; import ReactSelect, {components} from 'react-select'; @@ -81,7 +81,7 @@ const Option = (props: any) => { }; const DropdownInput = (props: Props) => { - const {value, placeholder, className, addon, name, textPrefix, legend, onChange, styles, options, error, testId, ...otherProps} = props; + const {value, placeholder, className, addon, name, textPrefix, legend, onChange, styles, options, error, testId, required, ...otherProps} = props; const [focused, setFocused] = useState(false); @@ -99,30 +99,29 @@ const DropdownInput = (props: Props) => { const [customInputLabel, setCustomInputLabel] = useState(null); const ownValue = useRef(); - const ownOnChange = (value: T, action: ActionMeta) => { + const ownOnChange = useCallback((value: T, action: ActionMeta) => { ownValue.current = value; onChange(value, action); - }; - const validateInput = () => { - if (!props.required || (ownValue.current !== null && ownValue.current)) { + }, [onChange]); + + const validateInput = useCallback(() => { + if (!required || (ownValue.current !== null && ownValue.current)) { setCustomInputLabel(null); return; } const validationErrorMsg = formatMessage({id: 'widget.input.required', defaultMessage: 'This field is required'}); setCustomInputLabel({type: ItemStatus.ERROR, value: validationErrorMsg}); - }; - - const onInputBlur = (event: React.FocusEvent) => { - const {onBlur} = props; + }, [required, formatMessage]); + const onInputBlur = useCallback((event: React.FocusEvent) => { setFocused(false); validateInput(); - if (onBlur) { - onBlur(event); + if (otherProps.onBlur) { + otherProps.onBlur(event); } - }; + }, [otherProps.onBlur, validateInput]); const showLegend = Boolean(focused || value); const isError = error || customInputLabel?.type === 'error';