diff --git a/e2e-tests/cypress/package-lock.json b/e2e-tests/cypress/package-lock.json index 035b7a7309..3edc22da5b 100644 --- a/e2e-tests/cypress/package-lock.json +++ b/e2e-tests/cypress/package-lock.json @@ -11,8 +11,8 @@ "@babel/eslint-parser": "7.24.1", "@babel/eslint-plugin": "7.23.5", "@cypress/request": "3.0.1", - "@mattermost/client": "9.6.0", - "@mattermost/types": "9.6.0", + "@mattermost/client": "10.2.0", + "@mattermost/types": "10.2.0", "@testing-library/cypress": "10.0.1", "@types/async": "3.2.24", "@types/authenticator": "1.1.4", @@ -1723,16 +1723,16 @@ } }, "node_modules/@mattermost/client": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@mattermost/client/-/client-9.6.0.tgz", - "integrity": "sha512-H1SKLKWUSqbGb7OcSWS9PdIp9eFUPaotGhrOnjradpaAv5fLPmfTzkLL5ZwRTZ9j+hiOT+D/p3vpNBqjCItiNQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@mattermost/client/-/client-10.2.0.tgz", + "integrity": "sha512-rtrC72Gj66+CFuVGryex1z+IvBTHAEFY//z13i/2sm/tPDuWREJ0jJcxgB0XULrInDVN1k5oibu2aeqVkhrRNA==", "dev": true, "dependencies": { "form-data": "^4.0.0" }, "peerDependencies": { - "@mattermost/types": "*", - "typescript": "^4.3" + "@mattermost/types": "^9.3.0 || ^10.0.0", + "typescript": "^4.3.0 || ^5.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -1741,9 +1741,9 @@ } }, "node_modules/@mattermost/client/node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "dev": true, "dependencies": { "asynckit": "^0.4.0", @@ -1755,12 +1755,12 @@ } }, "node_modules/@mattermost/types": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@mattermost/types/-/types-9.6.0.tgz", - "integrity": "sha512-x3K4M+Qu7rutb7QgjzZsJwPoCMojvcBH46vv3woeiYq7NIIg6vTg4XkDB31PktGRJRr9BtaXuLi0b0hLY6XE3A==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@mattermost/types/-/types-10.2.0.tgz", + "integrity": "sha512-Qd8rnnvn4nfJ4wObG/a2sdfy7NshqMVGnd73AzP9/ZDjE/esm8cIL89Ks3n0FJ83wdw96Omd4lFuSxAm1ftVjw==", "dev": true, "peerDependencies": { - "typescript": "^4.3" + "typescript": "^4.3.0 || ^5.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -12619,18 +12619,18 @@ } }, "@mattermost/client": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@mattermost/client/-/client-9.6.0.tgz", - "integrity": "sha512-H1SKLKWUSqbGb7OcSWS9PdIp9eFUPaotGhrOnjradpaAv5fLPmfTzkLL5ZwRTZ9j+hiOT+D/p3vpNBqjCItiNQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@mattermost/client/-/client-10.2.0.tgz", + "integrity": "sha512-rtrC72Gj66+CFuVGryex1z+IvBTHAEFY//z13i/2sm/tPDuWREJ0jJcxgB0XULrInDVN1k5oibu2aeqVkhrRNA==", "dev": true, "requires": { "form-data": "^4.0.0" }, "dependencies": { "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "dev": true, "requires": { "asynckit": "^0.4.0", @@ -12641,9 +12641,9 @@ } }, "@mattermost/types": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@mattermost/types/-/types-9.6.0.tgz", - "integrity": "sha512-x3K4M+Qu7rutb7QgjzZsJwPoCMojvcBH46vv3woeiYq7NIIg6vTg4XkDB31PktGRJRr9BtaXuLi0b0hLY6XE3A==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@mattermost/types/-/types-10.2.0.tgz", + "integrity": "sha512-Qd8rnnvn4nfJ4wObG/a2sdfy7NshqMVGnd73AzP9/ZDjE/esm8cIL89Ks3n0FJ83wdw96Omd4lFuSxAm1ftVjw==", "dev": true, "requires": {} }, diff --git a/e2e-tests/cypress/package.json b/e2e-tests/cypress/package.json index 6dc6f22b7c..1c7f8c07a8 100644 --- a/e2e-tests/cypress/package.json +++ b/e2e-tests/cypress/package.json @@ -6,8 +6,8 @@ "@babel/eslint-parser": "7.24.1", "@babel/eslint-plugin": "7.23.5", "@cypress/request": "3.0.1", - "@mattermost/client": "9.6.0", - "@mattermost/types": "9.6.0", + "@mattermost/client": "10.2.0", + "@mattermost/types": "10.2.0", "@testing-library/cypress": "10.0.1", "@types/async": "3.2.24", "@types/authenticator": "1.1.4", diff --git a/e2e-tests/cypress/tests/integration/channels/channel/channel_bookmarks_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel/channel_bookmarks_spec.ts index 1148c1dfdb..412ebe5fe6 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/channel_bookmarks_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel/channel_bookmarks_spec.ts @@ -25,209 +25,368 @@ describe('Channel Bookmarks', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars let user1: UserProfile; let admin: UserProfile; - let channel: Channel; + let publicChannel: Channel; + let privateChannel: Channel; + + const BOOKMARK_LIMIT = 50; before(() => { + cy.apiRequireLicense(); + cy.apiGetMe().then(({user: adminUser}) => { admin = adminUser; - cy.apiInitSetup().then(({team, user}) => { + cy.apiInitSetup().then(({team, user, channel}) => { testTeam = team; user1 = user; + publicChannel = channel; - cy.visit(`/${testTeam.name}/channels/town-square`); - cy.getCurrentChannelId().then((channelId) => { - cy.makeClient().then(async (client) => { - channel = await client.getChannel(channelId); - }); + cy.apiCreateChannel(testTeam.id, 'private-channel', 'private channel', 'P').then((result) => { + privateChannel = result.channel; + cy.apiAddUserToChannel(privateChannel.id, user1.id); }); + + cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); }); }); }); - it('create link bookmark', () => { - // # Create link - const {link, realLink} = createLinkBookmark(); + describe('functionality', () => { + it('bookmarks bar hidden when empty', () => { + // # Go to channel menu + cy.uiGetChannelInfoButton(); + cy.makeClient().then(async (client) => { + const bookmarks = await client.getChannelBookmarks(publicChannel.id); + cy.wrap(bookmarks.length).should('eq', 0); + }); - cy.findByTestId('channel-bookmarks-container').within(() => { + // * Verify bookmarks bar not present + cy.findByTestId('channel-bookmarks-container').should('not.exist'); + }); + + it('create link bookmark from channel menu', () => { + // # Create link + const {link, realLink} = createLinkBookmark({fromChannelMenu: true}); + + // * Verify bar now visible + cy.findByTestId('channel-bookmarks-container').within(() => { // * Verify href - cy.findByRole('link', {name: link}).should('have.attr', 'href', realLink); + cy.findByRole('link', {name: link}).should('have.attr', 'href', realLink); + }); }); - }); - it('create link bookmark, with emoji and custom title', () => { - const {realLink, displayName, emojiName} = createLinkBookmark({displayName: 'custom display name', emojiName: 'smiling_face_with_3_hearts'}); + it('create link bookmark', () => { + // # Create link + const {link, realLink} = createLinkBookmark(); - cy.findByTestId('channel-bookmarks-container').within(() => { + cy.findByTestId('channel-bookmarks-container').within(() => { + // * Verify href + cy.findByRole('link', {name: link}).should('have.attr', 'href', realLink); + }); + }); + + it('create link bookmark, with emoji and custom title', () => { + const {realLink, displayName, emojiName} = createLinkBookmark({displayName: 'custom display name', emojiName: 'smiling_face_with_3_hearts'}); + + cy.findByTestId('channel-bookmarks-container').within(() => { // * Verify emoji, displayname, and href - cy.findByRole('link', {name: `:${emojiName}: ${displayName}`}).should('have.attr', 'href', realLink); + cy.findByRole('link', {name: `:${emojiName}: ${displayName}`}).should('have.attr', 'href', realLink); + }); }); - }); - it('create file bookmark, and open preview', () => { + it('create file bookmark from channel menu', () => { // # Create bookmark - const {file} = createFileBookmark({file: 'small-image.png'}); + const {file} = createFileBookmark({file: 'mp4-video-file.mp4', fromChannelMenu: true}); - // * Verify preview icon - cy.findByRole('link', {name: file}).as('link').find('.file-icon.image'); - - // # Open preview - cy.get('@link').click(); - - // * Verify preview opened - cy.get('.file-preview-modal').findByRole('heading', {name: file}); - cy.get('.file-preview-modal__file-details-user-name').should('have.text', admin.username); - cy.get('.file-preview-modal__channel').should('have.text', `Shared in ~${channel.display_name}`); - cy.get('.icon-close').click(); - }); - - it('create file bookmark, progress and cancel upload', () => { - const file = 'powerpointx-file.pptx'; - - cy.intercept( - {method: 'POST', pathname: 'files', middleware: true, times: 1}, - () => { - return new Promise((resolve) => - setTimeout(() => resolve(), 2000), - ); - }).as('uploadRequest'); + // * Verify uploaded + cy.findByRole('link', {name: file}); + }); + it('create file bookmark, and open preview', () => { // # Create bookmark - createFileBookmark({file, save: false}); + const {file} = createFileBookmark({file: 'small-image.png'}); - // # Cancel upload - cy.get('a.file-preview__remove').click(); + // * Verify preview icon + cy.findByRole('link', {name: file}).as('link').find('.file-icon.image'); - // * Verify empty preview container - cy.get('.file-preview__container.empty'); + // # Open preview + cy.get('@link').click(); - // * Verify upload cancelled - cy.wait('@uploadRequest').its('state').should('eq', 'Errored'); + // * Verify preview opened + cy.get('.file-preview-modal').findByRole('heading', {name: file}); + cy.get('.file-preview-modal__file-details-user-name').should('have.text', admin.username); + cy.get('.file-preview-modal__channel').should('have.text', `Shared in ~${publicChannel.display_name}`); + cy.get('.icon-close').click(); + }); - // * Verify cannot save - cy.findByRole('button', {name: 'Add bookmark'}).should('be.disabled'); + it('create file bookmark, progress and cancel upload', () => { + const file = 'powerpointx-file.pptx'; - // # Try upload file again - cy.get('#bookmark-create-file-input-in-modal').attachFile(file); + cy.intercept( + {method: 'POST', pathname: 'files', middleware: true, times: 1}, + () => { + return new Promise((resolve) => + setTimeout(() => resolve(), 2000), + ); + }).as('uploadRequest'); - // * Verify uploaded - cy.findByTestId('titleInput').should('have.value', file); - cy.findByRole('link', {name: `file thumbnail ${file}`}); + // # Create bookmark + createFileBookmark({file, save: false}); - // # Save - editModalCreate(); + // # Cancel upload + cy.get('a.file-preview__remove').click(); - // * Verify bookmark created - cy.findByRole('link', {name: file}); - }); + // * Verify empty preview container + cy.get('.file-preview__container.empty'); - it('create file bookmark, with emoji and custom title', () => { + // * Verify upload cancelled + cy.wait('@uploadRequest').its('state').should('eq', 'Errored'); + + // * Verify cannot save + cy.findByRole('button', {name: 'Add bookmark'}).should('be.disabled'); + + // # Try upload file again + cy.get('#bookmark-create-file-input-in-modal').attachFile(file); + + // * Verify uploaded + cy.findByTestId('titleInput').should('have.value', file); + cy.findByRole('link', {name: `file thumbnail ${file}`}); + + // # Save + editModalCreate(); + + // * Verify bookmark created + cy.findByRole('link', {name: file}); + }); + + it('create file bookmark, with emoji and custom title', () => { // # Create bookmark - const {file, displayName, emojiName} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname small-image', emojiName: 'smiling_face_with_3_hearts'}); + const {file, displayName, emojiName} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname small-image', emojiName: 'smiling_face_with_3_hearts'}); - // * Verify emoji and custom display name - cy.findByRole('link', {name: `:${emojiName}: ${displayName}`}).click(); + // * Verify emoji and custom display name + cy.findByRole('link', {name: `:${emojiName}: ${displayName}`}).click(); - // * Verify preview opened - cy.get('.file-preview-modal').findByRole('heading', {name: file}); - cy.get('.icon-close').click(); - }); + // * Verify preview opened + cy.get('.file-preview-modal').findByRole('heading', {name: file}); + cy.get('.icon-close').click(); + }); - it('edit link bookmark', () => { + it('edit link bookmark', () => { // # Create link - const {displayName} = createLinkBookmark(); + const {displayName} = createLinkBookmark(); - const nextLink = 'google.com/q=test001'; - const realNextLink = `http://${nextLink}`; - const nextDisplayName = 'Next custom display name'; - const nextEmojiName = 'handshake'; + const nextLink = 'google.com/q=test001'; + const realNextLink = `http://${nextLink}`; + const nextDisplayName = 'Next custom display name'; + const nextEmojiName = 'handshake'; - // # Open edit - openEditModal(displayName); + // # Open edit + openEditModal(displayName); - // # Change link, displayname, emoji - editTextInput('linkInput', nextLink); - editTextInput('titleInput', nextDisplayName); - selectEmoji(nextEmojiName); + // # Change link, displayname, emoji + editTextInput('linkInput', nextLink); + editTextInput('titleInput', nextDisplayName); + selectEmoji(nextEmojiName); - // # Save - editModalSave(); + // # Save + editModalSave(); - // * Verify changes - cy.findAllByRole('link', {name: `:${nextEmojiName}: ${nextDisplayName}`}).should('have.attr', 'href', realNextLink); - }); + // * Verify changes + cy.findAllByRole('link', {name: `:${nextEmojiName}: ${nextDisplayName}`}).should('have.attr', 'href', realNextLink); + }); - it('edit link bookmark, only display name and emoji', () => { + it('edit link bookmark, only display name and emoji', () => { // # Create link - const {displayName, realLink} = createLinkBookmark(); + const {displayName, realLink} = createLinkBookmark(); - const nextDisplayName = 'Next custom display name 2'; - const nextEmojiName = 'handshake'; + const nextDisplayName = 'Next custom display name 2'; + const nextEmojiName = 'handshake'; - // # Open edit - openEditModal(displayName); + // # Open edit + openEditModal(displayName); - // # Change link, displayname, emoji - editTextInput('titleInput', nextDisplayName); - selectEmoji(nextEmojiName); + // # Change link, displayname, emoji + editTextInput('titleInput', nextDisplayName); + selectEmoji(nextEmojiName); - // # Save - editModalSave(); + // # Save + editModalSave(); - // * Verify changes - cy.findAllByRole('link', {name: `:${nextEmojiName}: ${nextDisplayName}`}).should('have.attr', 'href', realLink); - }); + // * Verify changes + cy.findAllByRole('link', {name: `:${nextEmojiName}: ${nextDisplayName}`}).should('have.attr', 'href', realLink); + }); - it('delete bookmark', () => { - const {displayName} = createLinkBookmark(); + it('delete bookmark', () => { + const {displayName} = createLinkBookmark(); - // * Verify bookmark exists - cy.findByRole('link', {name: displayName}); + // * Verify bookmark exists + cy.findByRole('link', {name: displayName}); - // # Start delete bookmark flow - openDotMenu(displayName); - cy.findByRole('menuitem', {name: 'Delete'}).click(); - cy.findByRole('dialog', {name: 'Delete bookmark'}).within(() => { + // # Start delete bookmark flow + openDotMenu(displayName); + cy.findByRole('menuitem', {name: 'Delete'}).click(); + cy.findByRole('dialog', {name: 'Delete bookmark'}).within(() => { // * Verify delete dialog contents - cy.findByRole('heading', {name: 'Delete bookmark'}); - cy.contains(`Are you sure you want to delete the bookmark ${displayName}?`); + cy.findByRole('heading', {name: 'Delete bookmark'}); + cy.contains(`Are you sure you want to delete the bookmark ${displayName}?`); - // # Delete bookmark - cy.findByRole('button', {name: 'Yes, delete'}).click(); + // # Delete bookmark + cy.findByRole('button', {name: 'Yes, delete'}).click(); + }); + + // * Verify bookmark deleted + cy.findByRole('link', {name: displayName}).should('not.exist'); }); - // * Verify bookmark deleted - cy.findByRole('link', {name: displayName}).should('not.exist'); - }); + it('reorder bookmark', () => { + const {displayName: name1} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname 1'}); + const {displayName: name2} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname 2'}); - it('reorder bookmark', () => { - const {displayName: name1} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname 1'}); - const {displayName: name2} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname 2'}); + // # Start reorder bookmark flow + cy.findByTestId('channel-bookmarks-container').within(() => { + cy.findAllByRole('link').should('be.visible').as('bookmarks'); + cy.get('@bookmarks').eq(-1).scrollIntoView(); + cy.get('@bookmarks').eq(-2).should('contain', name1); + cy.get('@bookmarks').eq(-1).should('contain', name2); - // # Start reorder bookmark flow - cy.findByTestId('channel-bookmarks-container').within(() => { - cy.findAllByRole('link').should('be.visible').as('bookmarks'); - cy.get('@bookmarks').eq(-1).scrollIntoView(); - cy.get('@bookmarks').eq(-2).should('contain', name1); - cy.get('@bookmarks').eq(-1).should('contain', name2); + // # Perform drag using keyboard + cy.get(`a:contains(${name1})`). + trigger('keydown', {keyCode: SpaceKeyCode}). + trigger('keydown', {keyCode: RightArrowKeyCode, force: true}).wait(TIMEOUTS.THREE_SEC). + trigger('keydown', {keyCode: SpaceKeyCode, force: true}).wait(TIMEOUTS.THREE_SEC); - // # Perform drag using keyboard - cy.get(`a:contains(${name1})`). - trigger('keydown', {keyCode: SpaceKeyCode}). - trigger('keydown', {keyCode: RightArrowKeyCode, force: true}).wait(TIMEOUTS.THREE_SEC). - trigger('keydown', {keyCode: SpaceKeyCode, force: true}).wait(TIMEOUTS.THREE_SEC); - - // * Verify correct order - cy.findAllByRole('link').should('be.visible').as('bookmarks-after'); - cy.get('@bookmarks-after').eq(-2).should('contain', name2); - cy.get('@bookmarks-after').eq(-1).should('contain', name1); + // * Verify correct order + cy.findAllByRole('link').should('be.visible').as('bookmarks-after'); + cy.get('@bookmarks-after').eq(-2).should('contain', name2); + cy.get('@bookmarks-after').eq(-1).should('contain', name1); + }); }); }); + + describe('manage bookmarks - permissions enforcement', () => { + before(() => { + // # Prep: disable manage bookmarks for users - public and private channels + cy.uiResetPermissionsToDefault(); + cy.findByTestId('all_users-public_channel-manage_public_channel_bookmarks-checkbox').click(); + cy.findByTestId('all_users-private_channel-manage_private_channel_bookmarks-checkbox').click(); + cy.uiSaveConfig(); + cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); + }); + + after(() => { + // # Reset permissions + cy.apiAdminLogin(); + cy.uiResetPermissionsToDefault(); + cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); + }); + + it('can add bookmark: public channel, admin', () => { + // # To public channel with bookmark + createLinkBookmark({fromChannelMenu: true}); + + // * Verify admin can create in public channel + verifyCanCreate(); + }); + + it('can add bookmark: private channel, admin', () => { + // # To private channel with bookmark + cy.visit(`/${testTeam.name}/channels/${privateChannel.name}`); + createLinkBookmark({fromChannelMenu: true}); + + // * Verify admin can create in private channel + verifyCanCreate(); + }); + + it('cannot add bookmark: private channel, non-admin', () => { + // # Switch to non-admin user + cy.apiLogin(user1); + + // # private channel + cy.visit(`/${testTeam.name}/channels/${privateChannel.name}`); + + // * Verify non-admin user cannot create in private channel + verifyCannotCreate(); + }); + + it('cannot add bookmark: public channel, non-admin', () => { + // # To public channel with bookmark + cy.visit(`/${testTeam.name}/channels/${publicChannel.name}`); + + // * Verify non-admin user cannot create in public channel + verifyCannotCreate(); + }); + + const verifyCanCreate = () => { + // * Verify can access create UI - bookmarks bar + promptAddLink(); + cy.uiCloseModal('Add a bookmark'); + + // * Verify can access create UI - channel menu + promptAddLink(true); + cy.uiCloseModal('Add a bookmark'); + }; + + const verifyCannotCreate = () => { + // * Verify cannot access create UI - channel menu + cy.uiOpenChannelMenu(); + cy.findByRole('menuitem', {name: 'Bookmarks Bar submenu icon'}).should('not.exist'); + + // * Verify cannot access create UI - bookmarks bar + cy.get('#channelBookmarksPlusMenuButton').should('not.exist'); + }; + }); + + describe('limits enforced', () => { + it('max bookmarks', () => { + // # Create links, fill to max + makeBookmarks(publicChannel); + + // * Verify add bookmark button is disabled + cy.findAllByRole('button', {name: 'Add a bookmark'}).should('be.disabled'); + + // * Verify create bookmark submenu in channel menu is not shown + cy.uiOpenChannelMenu(); + cy.findByRole('menuitem', {name: 'Bookmarks Bar submenu icon'}).should('not.exist'); + }); + }); + + function makeBookmarks(channel: Channel, n?: number) { + cy.makeClient().then(async (client) => { + const nToMake = n ?? BOOKMARK_LIMIT - (await client.getChannelBookmarks(channel.id))?.length; + + await Promise.allSettled(Array(nToMake).fill(0).map(() => { + return client.createChannelBookmark(publicChannel.id, { + type: 'link', + display_name: 'google.com', + link_url: `https://google.com/?q=test${getRandomId(7)}`, + }, ''); + })); + }); + } }); -function promptAddLink() { - cy.get('#channelBookmarksPlusMenuButton').click(); - cy.get('#channelBookmarksAddLink').click(); +function promptAddLink(fromChannelMenu = false) { + if (fromChannelMenu) { + cy.uiOpenChannelMenu(); + + cy.findByRole('menuitem', {name: 'Bookmarks Bar submenu icon'}).trigger('mouseover'); + cy.findByRole('menuitem', {name: 'Add a link not selected'}).click(); + } else { + cy.get('#channelBookmarksPlusMenuButton').click(); + cy.get('#channelBookmarksAddLink').click(); + } +} + +function promptAddFile(fromChannelMenu = false) { + if (fromChannelMenu) { + cy.uiOpenChannelMenu(); + + cy.findByRole('menuitem', {name: 'Bookmarks Bar submenu icon'}).trigger('mouseover'); + cy.findByRole('menuitem', {name: 'Attach a file not selected'}).click(); + } else { + cy.get('#channelBookmarksPlusMenuButton').click(); + cy.get('#channelBookmarksAttachFile').click(); + } } function openEditModal(name: string) { @@ -256,11 +415,12 @@ function createLinkBookmark({ displayName = '', emojiName = '', // e.g. smile save = true, + fromChannelMenu = false, } = {}) { const realLink = `http://${link}`; // # Add link - promptAddLink(); + promptAddLink(fromChannelMenu); // # Enter link editTextInput('linkInput', link); @@ -290,8 +450,10 @@ function createFileBookmark({ displayName = '', emojiName = '', // e.g. smile save = true, + fromChannelMenu = false, } = {}) { - cy.get('#bookmark-create-file-input').attachFile(file); + promptAddFile(fromChannelMenu); + cy.get('#root-portal #bookmark-create-file-input').attachFile(file); if (displayName) { // # Enter displayname diff --git a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks.tsx b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks.tsx index 0e901102a3..60fa72ff6d 100644 --- a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks.tsx +++ b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks.tsx @@ -11,7 +11,7 @@ import type {IDMappedObjects} from '@mattermost/types/utilities'; import BookmarkItem from './bookmark_item'; import BookmarksMenu from './channel_bookmarks_menu'; -import {useChannelBookmarkPermission, useChannelBookmarks, MAX_BOOKMARKS_PER_CHANNEL, useCanUploadFiles} from './utils'; +import {useChannelBookmarks, MAX_BOOKMARKS_PER_CHANNEL, useCanUploadFiles, useChannelBookmarkPermission} from './utils'; import './channel_bookmarks.scss'; @@ -23,12 +23,12 @@ function ChannelBookmarks({ channelId, }: Props) { const {order, bookmarks, reorder} = useChannelBookmarks(channelId); + const canReorder = useChannelBookmarkPermission(channelId, 'order'); const canUploadFiles = useCanUploadFiles(); - const canAdd = useChannelBookmarkPermission(channelId, 'add'); const hasBookmarks = Boolean(order?.length); const limitReached = order.length >= MAX_BOOKMARKS_PER_CHANNEL; - if (!hasBookmarks && !canAdd) { + if (!hasBookmarks) { return null; } @@ -53,7 +53,7 @@ function ChannelBookmarks({ data-testid='channel-bookmarks-container' {...drop.droppableProps} > - {order.map(makeItemRenderer(bookmarks, snap.isDraggingOver))} + {order.map(makeItemRenderer(bookmarks, snap.isDraggingOver, !canReorder))} {drop.placeholder} , disableInteractions: boolean) => (id: string, index: number) => { +const makeItemRenderer = (bookmarks: IDMappedObjects, disableInteractions: boolean, disableDrag: boolean) => (id: string, index: number) => { return ( {(drag, snap) => { return ( diff --git a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_menu.tsx b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_menu.tsx index 3a24a22508..bffc4047c4 100644 --- a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_menu.tsx +++ b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_menu.tsx @@ -2,8 +2,7 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; -import type {ChangeEvent} from 'react'; -import React, {useCallback, useRef} from 'react'; +import React, {memo, useCallback} from 'react'; import {useIntl} from 'react-intl'; import {useDispatch} from 'react-redux'; import styled, {css} from 'styled-components'; @@ -15,73 +14,32 @@ import { } from '@mattermost/compass-icons/components'; import type {ChannelBookmarkCreate} from '@mattermost/types/channel_bookmarks'; -import type {ActionResult} from 'mattermost-redux/types/actions'; - import {createBookmark} from 'actions/channel_bookmarks'; import {openModal} from 'actions/views/modals'; import * as Menu from 'components/menu'; import {ModalIdentifiers} from 'utils/constants'; -import {clearFileInput} from 'utils/utils'; import ChannelBookmarkCreateModal from './channel_bookmarks_create_modal'; -import {MAX_BOOKMARKS_PER_CHANNEL} from './utils'; +import {MAX_BOOKMARKS_PER_CHANNEL, useChannelBookmarkPermission} from './utils'; type BookmarksMenuProps = { channelId: string; hasBookmarks: boolean; limitReached: boolean; canUploadFiles: boolean;}; -export default ({ +function BookmarksMenu({ channelId, hasBookmarks, limitReached, canUploadFiles, -}: BookmarksMenuProps) => { +}: BookmarksMenuProps) { const {formatMessage} = useIntl(); - const dispatch = useDispatch(); const showLabel = !hasBookmarks; - const handleCreate = useCallback((file?: File) => { - dispatch(openModal({ - modalId: ModalIdentifiers.CHANNEL_BOOKMARK_CREATE, - dialogType: ChannelBookmarkCreateModal, - dialogProps: { - channelId, - bookmarkType: file ? 'file' : 'link', - file, - onConfirm: async (data: ChannelBookmarkCreate) => dispatch(createBookmark(channelId, data)) as ActionResult, - }, - })); - }, [channelId, dispatch]); - - const handleFileChanged = useCallback((e: ChangeEvent) => { - if (e.target.files?.length) { - const [file] = e.target.files; - handleCreate(file); - clearFileInput(e.target); - } - }, [handleCreate]); - - const fileInputRef = useRef(null); - const fileInput = ( - - ); - - const handleCreateLink = useCallback(() => { - handleCreate(); - }, [handleCreate]); - - const handleCreateFile = useCallback(() => { - fileInputRef.current?.click(); - }, [fileInputRef.current]); + const {handleCreateLink, handleCreateFile} = useBookmarkAddActions(channelId); + const canAdd = useChannelBookmarkPermission(channelId, 'add'); const addBookmarkLabel = formatMessage({id: 'channel_bookmarks.addBookmark', defaultMessage: 'Add a bookmark'}); @@ -93,9 +51,14 @@ export default ({ } else if (hasBookmarks) { addBookmarkTooltipText = addBookmarkLabel; } + const addLinkLabel = formatMessage({id: 'channel_bookmarks.addLink', defaultMessage: 'Add a link'}); const attachFileLabel = formatMessage({id: 'channel_bookmarks.attachFile', defaultMessage: 'Attach a file'}); + if (!canAdd) { + return null; + } + return ( } labels={{addLinkLabel}} - aria-label={addLinkLabel} /> {canUploadFiles && ( } labels={{attachFileLabel}} - aria-label={attachFileLabel} /> )} - {fileInput} ); -}; +} + +export default memo(BookmarksMenu); const MenuButtonContainer = styled.div<{withLabel: boolean}>` position: sticky; @@ -153,3 +115,46 @@ const MenuButtonContainer = styled.div<{withLabel: boolean}>` ${({withLabel}) => !withLabel && css`padding: 0 1rem;`} background: linear-gradient(to right, rgba(var(--center-channel-bg-rgb), .16), rgba(var(--center-channel-bg-rgb), 1) 25%); `; + +export const useBookmarkAddActions = (channelId: string) => { + const dispatch = useDispatch(); + + const handleCreate = useCallback((file?: File) => { + dispatch(openModal({ + modalId: ModalIdentifiers.CHANNEL_BOOKMARK_CREATE, + dialogType: ChannelBookmarkCreateModal, + dialogProps: { + channelId, + bookmarkType: file ? 'file' : 'link', + file, + onConfirm: async (data: ChannelBookmarkCreate) => dispatch(createBookmark(channelId, data)), + }, + })); + }, [channelId, dispatch]); + + const handleCreateLink = useCallback(() => { + handleCreate(); + }, [handleCreate]); + + const handleCreateFile = useCallback(() => { + const input: HTMLInputElement = document.createElement('input'); + input.type = 'file'; + input.id = 'bookmark-create-file-input'; + input.hidden = true; + + input.addEventListener('change', () => { + const file = input.files?.[0]; + if (file) { + handleCreate(file); + } + input.remove(); + }); + input.addEventListener('cancel', input.remove); + + document.getElementById('root-portal')?.appendChild(input); + + input.click(); + }, [handleCreate]); + + return {handleCreateLink, handleCreateFile}; +}; diff --git a/webapp/channels/src/components/channel_bookmarks_sub_menu/index.tsx b/webapp/channels/src/components/channel_bookmarks_sub_menu/index.tsx new file mode 100644 index 0000000000..5687f2b553 --- /dev/null +++ b/webapp/channels/src/components/channel_bookmarks_sub_menu/index.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Purpose of this file to exists is only required until channel header dropdown is migrated to new menus +import type {ComponentProps} from 'react'; +import React, {memo} from 'react'; +import {useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import { + LinkVariantIcon, + PaperclipIcon, +} from '@mattermost/compass-icons/components'; +import type {Channel} from '@mattermost/types/channels'; + +import {getChannelBookmarks} from 'mattermost-redux/selectors/entities/channel_bookmarks'; + +import {useBookmarkAddActions} from 'components/channel_bookmarks/channel_bookmarks_menu'; +import {MAX_BOOKMARKS_PER_CHANNEL, useCanUploadFiles, useChannelBookmarkPermission} from 'components/channel_bookmarks/utils'; +import Menu from 'components/widgets/menu/menu'; + +import type {GlobalState} from 'types/store'; + +type Props = { + channel: Channel; + inHeaderDropdown?: boolean; +}; + +const ChannelBookmarksSubmenu = (props: Props) => { + const {formatMessage} = useIntl(); + + const {handleCreateLink, handleCreateFile} = useBookmarkAddActions(props.channel.id); + const canAdd = useChannelBookmarkPermission(props.channel.id, 'add'); + const canUploadFiles = useCanUploadFiles(); + const limitReached = useSelector((state: GlobalState) => { + const bookmarks = getChannelBookmarks(state, props.channel.id); + return bookmarks && Object.keys(bookmarks).length >= MAX_BOOKMARKS_PER_CHANNEL; + }); + + if (!canAdd || limitReached) { + return null; + } + + const items: ComponentProps['subMenu'] = [ + { + id: 'channelBookmarksAddLink', + icon: , + direction: 'right', + text: formatMessage({id: 'channel_bookmarks.addLink', defaultMessage: 'Add a link'}), + action: handleCreateLink, + }, + ]; + + if (canUploadFiles) { + items.push({ + id: 'channelBookmarksAttachFile', + icon: , + direction: 'right', + text: formatMessage({id: 'channel_bookmarks.attachFile', defaultMessage: 'Attach a file'}), + action: handleCreateFile, + }); + } + + return ( + + ); +}; + +export default memo(ChannelBookmarksSubmenu); diff --git a/webapp/channels/src/components/channel_header_dropdown/channel_header_dropdown.test.tsx b/webapp/channels/src/components/channel_header_dropdown/channel_header_dropdown.test.tsx index 29a2c503dd..39cc94e838 100644 --- a/webapp/channels/src/components/channel_header_dropdown/channel_header_dropdown.test.tsx +++ b/webapp/channels/src/components/channel_header_dropdown/channel_header_dropdown.test.tsx @@ -22,6 +22,7 @@ describe('components/ChannelHeaderDropdown', () => { penultimateViewedChannelName: 'test-channel', pluginMenuItems: [], isLicensedForLDAPGroups: false, + isChannelBookmarksEnabled: false, }; test('should match snapshot with no plugin items', () => { const wrapper = shallow(); diff --git a/webapp/channels/src/components/channel_header_dropdown/channel_header_dropdown_items.tsx b/webapp/channels/src/components/channel_header_dropdown/channel_header_dropdown_items.tsx index 03de0b6270..75f7b93f26 100644 --- a/webapp/channels/src/components/channel_header_dropdown/channel_header_dropdown_items.tsx +++ b/webapp/channels/src/components/channel_header_dropdown/channel_header_dropdown_items.tsx @@ -10,6 +10,7 @@ import {Permissions} from 'mattermost-redux/constants'; import {isGuest} from 'mattermost-redux/utils/user_utils'; import AddGroupsToChannelModal from 'components/add_groups_to_channel_modal'; +import ChannelBookmarksSubmenu from 'components/channel_bookmarks_sub_menu'; import ChannelGroupsManageModal from 'components/channel_groups_manage_modal'; import ChannelInviteModal from 'components/channel_invite_modal'; import ChannelMoveToSubMenuOld from 'components/channel_move_to_sub_menu_old'; @@ -52,6 +53,7 @@ export type Props = { penultimateViewedChannelName: string; pluginMenuItems: PluginComponent[]; isLicensedForLDAPGroups: boolean; + isChannelBookmarksEnabled: boolean; } export default class ChannelHeaderDropdown extends React.PureComponent { @@ -67,6 +69,7 @@ export default class ChannelHeaderDropdown extends React.PureComponent { isMobile, penultimateViewedChannelName, isLicensedForLDAPGroups, + isChannelBookmarksEnabled, } = this.props; if (!channel) { @@ -238,6 +241,7 @@ export default class ChannelHeaderDropdown extends React.PureComponent { + {isChannelBookmarksEnabled && } ({ penultimateViewedChannelName: getPenultimateViewedChannelName(state) || getRedirectChannelNameForTeam(state, getCurrentTeamId(state)), pluginMenuItems: getChannelHeaderMenuPluginComponents(state), isLicensedForLDAPGroups: state.entities.general.license.LDAPGroups === 'true', + isChannelBookmarksEnabled: getIsChannelBookmarksEnabled(state), }); const mobileMapStateToProps = (state: GlobalState) => { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 09a2648033..dbfe020d3a 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -5138,6 +5138,7 @@ "sidebar_left.sidebar_category.newLabel": "new", "sidebar_left.sidebar_channel_menu_convert_to_channel": "Convert to Private Channel", "sidebar_left.sidebar_channel_menu.addMembers": "Add Members", + "sidebar_left.sidebar_channel_menu.bookmarks": "Bookmarks Bar", "sidebar_left.sidebar_channel_menu.channels": "Channels", "sidebar_left.sidebar_channel_menu.copyLink": "Copy Link", "sidebar_left.sidebar_channel_menu.dropdownAriaLabel": "Edit channel menu",