From cef51348657d6e10449373c22e5e2eb5286adb07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Tue, 17 Feb 2026 17:40:29 -0500 Subject: [PATCH] Mm 65975 - migrate team modal to generic modal (#35256) * MM-65975 - migrate team modal to generic modal * add e2e tests to team settings * apply self review fixes, organize test pom and clean code * fix cypress tests --- .../system_console/environment_spec.js | 6 +- .../closed_team_invite_by_email_spec.js | 2 +- .../channels/team_settings/helpers.js | 2 +- .../team_settings/remove_team_icon_spec.js | 3 +- e2e-tests/playwright/lib/src/server/index.ts | 1 - .../channels/team_settings/access_settings.ts | 55 ++ .../channels/team_settings/info_settings.ts | 53 ++ .../team_settings/team_settings_modal.ts | 63 +++ .../playwright/lib/src/ui/pages/channels.ts | 17 +- .../team_settings/team_settings_modal.spec.ts | 489 ++++++++++++++++++ .../mobile_sidebar_right_items.tsx | 3 + .../sidebar_header/sidebar_team_menu.tsx | 1 + .../team_settings/team_access_tab/index.ts | 11 +- .../team_access_tab/team_access_tab.test.tsx | 11 +- .../team_access_tab/team_access_tab.tsx | 131 ++--- .../team_settings/team_info_tab/index.ts | 11 +- .../team_info_tab/team_info_tab.test.tsx | 11 +- .../team_info_tab/team_info_tab.tsx | 146 ++---- .../team_settings/team_settings.tsx | 44 +- .../components/team_settings_modal/index.ts | 26 +- .../team_settings_modal.scss | 90 ++++ .../team_settings_modal.test.tsx | 170 +++--- .../team_settings_modal.tsx | 159 +++--- 23 files changed, 1096 insertions(+), 409 deletions(-) create mode 100644 e2e-tests/playwright/lib/src/ui/components/channels/team_settings/access_settings.ts create mode 100644 e2e-tests/playwright/lib/src/ui/components/channels/team_settings/info_settings.ts create mode 100644 e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_modal.spec.ts create mode 100644 webapp/channels/src/components/team_settings_modal/team_settings_modal.scss diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/environment_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/environment_spec.js index ad67f76ad18..1207c29055a 100644 --- a/e2e-tests/cypress/tests/integration/channels/system_console/environment_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/system_console/environment_spec.js @@ -49,7 +49,7 @@ describe('Environment', () => { cy.uiSave().wait(TIMEOUTS.HALF_SEC); // # Close the modal - cy.get('#teamSettingsModalLabel').find('button').should('be.visible').click(); + cy.get('button[aria-label="Close"]').should('be.visible').click(); }); // Validate that the image is being displayed @@ -93,7 +93,7 @@ describe('Environment', () => { cy.uiSave().wait(TIMEOUTS.HALF_SEC); // # Close the modal - cy.get('#teamSettingsModalLabel').find('button').should('be.visible').click(); + cy.get('button[aria-label="Close"]').should('be.visible').click(); }); // Validate that the image is being displayed @@ -137,7 +137,7 @@ describe('Environment', () => { cy.uiSave().wait(TIMEOUTS.HALF_SEC); // # Close the modal - cy.get('#teamSettingsModalLabel').find('button').should('be.visible').click(); + cy.get('button[aria-label="Close"]').should('be.visible').click(); }); // Validate that the image is being displayed diff --git a/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js b/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js index 6884526358a..5b0f895353d 100644 --- a/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js @@ -71,7 +71,7 @@ describe('Team Settings', () => { cy.get('#allowedDomains').should('have.text', 'corp.mattermost.com, mattermost.com'); // # Close the modal - cy.get('#teamSettingsModalLabel').find('button').should('be.visible').click(); + cy.get('button[aria-label="Close"]').should('be.visible').click(); }); // # Open the 'Invite People' full screen modal diff --git a/e2e-tests/cypress/tests/integration/channels/team_settings/helpers.js b/e2e-tests/cypress/tests/integration/channels/team_settings/helpers.js index a05e9835358..2a3e0548ca9 100644 --- a/e2e-tests/cypress/tests/integration/channels/team_settings/helpers.js +++ b/e2e-tests/cypress/tests/integration/channels/team_settings/helpers.js @@ -25,7 +25,7 @@ export const allowOnlyUserFromSpecificDomain = (domain) => { cy.findByText('Save').should('be.visible').click(); // # Close the modal - cy.get('#teamSettingsModalLabel').find('button').should('be.visible').click(); + cy.get('button[aria-label="Close"]').should('be.visible').click(); }); }; diff --git a/e2e-tests/cypress/tests/integration/channels/team_settings/remove_team_icon_spec.js b/e2e-tests/cypress/tests/integration/channels/team_settings/remove_team_icon_spec.js index 6c583cc398f..2dd7aa4c872 100644 --- a/e2e-tests/cypress/tests/integration/channels/team_settings/remove_team_icon_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/team_settings/remove_team_icon_spec.js @@ -68,7 +68,8 @@ function openTeamSettingsDialog() { cy.uiOpenTeamMenu('Team settings'); // * Verify the team settings dialog is open - cy.get('#teamSettingsModalLabel').should('be.visible').and('contain', 'Team Settings'); + cy.get('#teamSettingsModal').should('be.visible'); + cy.get('.modal-title').should('be.visible').and('contain', 'Team Settings'); cy.get('.team-picture-section').within(() => { // * Verify the edit icon is visible diff --git a/e2e-tests/playwright/lib/src/server/index.ts b/e2e-tests/playwright/lib/src/server/index.ts index 5be9379db5a..43cb1267370 100644 --- a/e2e-tests/playwright/lib/src/server/index.ts +++ b/e2e-tests/playwright/lib/src/server/index.ts @@ -9,4 +9,3 @@ export {createRandomPost} from './post'; export {createNewTeam, createRandomTeam} from './team'; export {createNewUserProfile, createRandomUser, getDefaultAdminUser, isOutsideRemoteUserHour} from './user'; export {installAndEnablePlugin, isPluginActive, getPluginStatus} from './plugin'; -//getPluginStatus diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/team_settings/access_settings.ts b/e2e-tests/playwright/lib/src/ui/components/channels/team_settings/access_settings.ts new file mode 100644 index 00000000000..7bcbe0b72dd --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/team_settings/access_settings.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Locator, expect} from '@playwright/test'; + +export default class AccessSettings { + readonly container: Locator; + + readonly allowedDomainsCheckbox; + readonly allowedDomainsInput; + readonly allowOpenInviteCheckbox; + readonly regenerateButton; + + constructor(container: Locator) { + this.container = container; + + this.allowedDomainsCheckbox = container.locator('input[name="showAllowedDomains"]'); + this.allowedDomainsInput = container.locator('#allowedDomains input'); + this.allowOpenInviteCheckbox = container.locator('input[name="allowOpenInvite"]'); + this.regenerateButton = container.locator('button[data-testid="regenerateButton"]'); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async enableAllowedDomains() { + const isChecked = await this.allowedDomainsCheckbox.isChecked(); + if (!isChecked) { + await this.allowedDomainsCheckbox.check(); + } + } + + async addDomain(domain: string) { + await expect(this.allowedDomainsInput).toBeVisible(); + await this.allowedDomainsInput.fill(domain); + await this.allowedDomainsInput.press('Enter'); + } + + async removeDomain(domain: string) { + const removeButton = this.container.locator(`div[role="button"][aria-label*="Remove ${domain}"]`); + await expect(removeButton).toBeVisible(); + await removeButton.click(); + } + + async toggleOpenInvite() { + await expect(this.allowOpenInviteCheckbox).toBeVisible(); + await this.allowOpenInviteCheckbox.click(); + } + + async regenerateInviteId() { + await expect(this.regenerateButton).toBeVisible(); + await this.regenerateButton.click(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/team_settings/info_settings.ts b/e2e-tests/playwright/lib/src/ui/components/channels/team_settings/info_settings.ts new file mode 100644 index 00000000000..d548864feea --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/team_settings/info_settings.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Locator, expect} from '@playwright/test'; + +export default class InfoSettings { + readonly container: Locator; + + readonly nameInput; + readonly descriptionInput; + readonly uploadInput; + readonly removeImageButton; + readonly teamIconImage; + readonly teamIconInitial; + + constructor(container: Locator) { + this.container = container; + + this.nameInput = container.locator('input#teamName'); + this.descriptionInput = container.locator('textarea#teamDescription'); + this.uploadInput = container.locator('input[data-testid="uploadPicture"]'); + this.removeImageButton = container.locator('button[data-testid="removeImageButton"]'); + this.teamIconImage = container.locator('#teamIconImage'); + this.teamIconInitial = container.locator('#teamIconInitial'); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async updateName(name: string) { + await expect(this.nameInput).toBeVisible(); + await this.nameInput.clear(); + await this.nameInput.fill(name); + } + + async updateDescription(description: string) { + await expect(this.descriptionInput).toBeVisible(); + await this.descriptionInput.clear(); + await this.descriptionInput.fill(description); + } + + async uploadIcon(filePath: string) { + await this.uploadInput.setInputFiles(filePath); + await expect(this.teamIconImage).toBeVisible(); + } + + async removeIcon() { + await expect(this.removeImageButton).toBeVisible(); + await this.removeImageButton.click(); + await expect(this.teamIconInitial).toBeVisible(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/team_settings/team_settings_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/team_settings/team_settings_modal.ts index 78910f415fc..534f68d1285 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/team_settings/team_settings_modal.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/team_settings/team_settings_modal.ts @@ -3,14 +3,77 @@ import {Locator, expect} from '@playwright/test'; +import InfoSettings from './info_settings'; +import AccessSettings from './access_settings'; + export default class TeamSettingsModal { readonly container: Locator; + readonly closeButton; + + readonly infoTab; + readonly accessTab; + + readonly saveButton; + readonly undoButton; + + readonly infoSettings; + readonly accessSettings; + constructor(container: Locator) { this.container = container; + + this.closeButton = container.locator('.modal-header button.close').first(); + + this.infoTab = container.locator('[data-testid="info-tab-button"]'); + this.accessTab = container.locator('[data-testid="access-tab-button"]'); + + this.saveButton = container.locator('button[data-testid="SaveChangesPanel__save-btn"]'); + this.undoButton = container.locator('button[data-testid="SaveChangesPanel__cancel-btn"]'); + + this.infoSettings = new InfoSettings(container); + this.accessSettings = new AccessSettings(container); } async toBeVisible() { await expect(this.container).toBeVisible(); } + + async close() { + await this.closeButton.click(); + } + + async openInfoTab(): Promise { + await expect(this.infoTab).toBeVisible(); + await this.infoTab.click(); + + return this.infoSettings; + } + + async openAccessTab(): Promise { + await expect(this.accessTab).toBeVisible(); + await this.accessTab.click(); + + return this.accessSettings; + } + + async save() { + await expect(this.saveButton).toBeVisible(); + await this.saveButton.click(); + } + + async undo() { + await expect(this.undoButton).toBeVisible(); + await this.undoButton.click(); + } + + async verifySavedMessage() { + const savedMessage = this.container.getByText('Settings saved'); + await expect(savedMessage).toBeVisible({timeout: 5000}); + } + + async verifyUnsavedChanges() { + const warningText = this.container.locator('.SaveChangesPanel:has-text("You have unsaved changes")'); + await expect(warningText).toBeVisible({timeout: 3000}); + } } diff --git a/e2e-tests/playwright/lib/src/ui/pages/channels.ts b/e2e-tests/playwright/lib/src/ui/pages/channels.ts index 7cb697470a1..6e9337f92f9 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/channels.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/channels.ts @@ -4,7 +4,14 @@ import {expect, Page} from '@playwright/test'; import {waitUntil} from 'async-wait-until'; -import {ChannelsPost, ChannelSettingsModal, SettingsModal, components, InvitePeopleModal} from '@/ui/components'; +import { + ChannelsPost, + ChannelSettingsModal, + SettingsModal, + TeamSettingsModal, + components, + InvitePeopleModal, +} from '@/ui/components'; import {duration} from '@/util'; export default class ChannelsPage { readonly channels = 'Channels'; @@ -152,6 +159,14 @@ export default class ChannelsPage { return {rootPost, sidebarRight, lastPost}; } + async openTeamSettings(): Promise { + await this.page.locator('#sidebarTeamMenuButton').click(); + await this.page.getByText('Team settings').first().click(); + await this.teamSettingsModal.toBeVisible(); + + return this.teamSettingsModal; + } + async openChannelSettings(): Promise { await this.centerView.header.openChannelMenu(); await this.page.locator('#channelSettings[role="menuitem"]').click(); diff --git a/e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_modal.spec.ts b/e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_modal.spec.ts new file mode 100644 index 00000000000..75360f21144 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_modal.spec.ts @@ -0,0 +1,489 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** + * @objective Complete E2E test suite for Team Settings Modal + * @reference MM-65975 - Migrate Team Settings Modal to GenericModal + */ + +import path from 'path'; + +import {ChannelsPage, expect, test} from '@mattermost/playwright-lib'; + +// Asset file path for team icon uploads +const TEAM_ICON_ASSET = path.resolve(__dirname, '../../../../lib/src/asset/mattermost-icon_128x128.png'); + +test.describe('Team Settings Modal - Complete Test Suite', () => { + /** + * MM-TXXXX: Open and close Team Settings Modal + * @objective Verify basic modal open/close functionality + */ + test('MM-TXXXX Open and close Team Settings Modal', async ({pw}) => { + // # Set up admin user and login + const {adminUser} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + + // # Navigate to a team + await channelsPage.goto(); + await page.waitForLoadState('networkidle'); + + // # Open Team Settings Modal + const teamSettings = await channelsPage.openTeamSettings(); + + // * Verify Info tab is selected by default + await expect(teamSettings.infoTab).toHaveAttribute('aria-selected', 'true'); + + // # Close modal + await teamSettings.close(); + + // * Verify modal closes + await expect(teamSettings.container).not.toBeVisible(); + }); + + /** + * MM-TXXXX: Edit team name and save changes + * @objective Verify team name can be edited and saved + */ + test('MM-TXXXX Edit team name and save changes', async ({pw}) => { + // # Set up admin user and login + const {adminUser, adminClient, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + + // # Navigate to team + await channelsPage.goto(team.name); + await page.waitForLoadState('networkidle'); + + // # Open Team Settings Modal + const teamSettings = await channelsPage.openTeamSettings(); + + // * Verify current team name is displayed + await expect(teamSettings.infoSettings.nameInput).toHaveValue(team.display_name); + + // # Edit team name + const newTeamName = `Updated Team ${await pw.random.id()}`; + await teamSettings.infoSettings.updateName(newTeamName); + + // # Save changes + await teamSettings.save(); + + // * Wait for "Settings saved" message + await teamSettings.verifySavedMessage(); + + // * Verify team name updated via API + const updatedTeam = await adminClient.getTeam(team.id); + expect(updatedTeam.display_name).toBe(newTeamName); + + // # Close modal + await teamSettings.close(); + + // * Verify modal closes without warning + await expect(teamSettings.container).not.toBeVisible(); + }); + + /** + * MM-TXXXX: Edit team description and save changes + * @objective Verify team description can be edited and saved + */ + test('MM-TXXXX Edit team description and save changes', async ({pw}) => { + // # Set up admin user and login + const {adminUser, adminClient, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + + // # Navigate to team + await channelsPage.goto(team.name); + await page.waitForLoadState('networkidle'); + + // # Open Team Settings Modal + const teamSettings = await channelsPage.openTeamSettings(); + + // # Edit team description + const newDescription = `Test description ${await pw.random.id()}`; + await teamSettings.infoSettings.updateDescription(newDescription); + + // # Save changes + await teamSettings.save(); + + // * Wait for "Settings saved" message + await teamSettings.verifySavedMessage(); + + // * Verify description updated via API + const updatedTeam = await adminClient.getTeam(team.id); + expect(updatedTeam.description).toBe(newDescription); + + // # Close modal + await teamSettings.close(); + + // * Verify modal closes + await expect(teamSettings.container).not.toBeVisible(); + }); + + /** + * MM-TXXXX: Warn on close with unsaved changes + * @objective Verify unsaved changes warning behavior (warn-once pattern) + */ + test('MM-TXXXX Warn on close with unsaved changes', async ({pw}) => { + // # Set up admin user and login + const {adminUser, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + + // # Navigate to team + await channelsPage.goto(team.name); + await page.waitForLoadState('networkidle'); + + // # Open Team Settings Modal + const teamSettings = await channelsPage.openTeamSettings(); + + // # Edit team name to create unsaved changes + const newTeamName = `Modified Team ${await pw.random.id()}`; + await teamSettings.infoSettings.updateName(newTeamName); + + // # Try to close modal (first attempt) + await teamSettings.close(); + + // * Verify "You have unsaved changes" warning appears + await teamSettings.verifyUnsavedChanges(); + + // * Verify Save button is visible + await expect(teamSettings.saveButton).toBeVisible(); + + // * Verify modal is still open + await expect(teamSettings.container).toBeVisible(); + + // # Try to close modal again (second attempt - warn-once behavior) + await teamSettings.close(); + + // * Verify modal closes on second attempt + await expect(teamSettings.container).not.toBeVisible(); + }); + + /** + * MM-TXXXX: Prevent tab switch with unsaved changes + * @objective Verify tab switching blocked with unsaved changes + */ + test('MM-TXXXX Prevent tab switch with unsaved changes', async ({pw}) => { + // # Set up admin user and login + const {adminUser, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + + // # Navigate to team + await channelsPage.goto(team.name); + await page.waitForLoadState('networkidle'); + + // # Open Team Settings Modal + const teamSettings = await channelsPage.openTeamSettings(); + + // * Verify Access tab is visible (admin has INVITE_USER permission) + await expect(teamSettings.accessTab).toBeVisible(); + + // # Edit team name in Info tab (create unsaved changes) + const newTeamName = `Modified Team ${await pw.random.id()}`; + await teamSettings.infoSettings.updateName(newTeamName); + + // # Try to switch to Access tab + await teamSettings.openAccessTab(); + + // * Verify "You have unsaved changes" error appears + await teamSettings.verifyUnsavedChanges(); + + // * Verify still on Info tab + await expect(teamSettings.infoTab).toHaveAttribute('aria-selected', 'true'); + + // # Click Undo button + await teamSettings.undo(); + + // * Verify can now switch to Access tab + await teamSettings.openAccessTab(); + await expect(teamSettings.accessTab).toHaveAttribute('aria-selected', 'true'); + }); + + /** + * MM-TXXXX: Save changes and close modal without warning + * @objective Verify that after saving, modal closes without warning + */ + test('MM-TXXXX Save changes and close modal without warning', async ({pw}) => { + // # Set up admin user and login + const {adminUser, adminClient, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + + // # Navigate to team + await channelsPage.goto(team.name); + await page.waitForLoadState('networkidle'); + + // # Open Team Settings Modal + const teamSettings = await channelsPage.openTeamSettings(); + + // # Edit team name + const newTeamName = `Updated Team ${await pw.random.id()}`; + await teamSettings.infoSettings.updateName(newTeamName); + + // # Save changes + await teamSettings.save(); + + // * Wait for "Settings saved" message + await teamSettings.verifySavedMessage(); + + // * Verify team name updated via API + const updatedTeam = await adminClient.getTeam(team.id); + expect(updatedTeam.display_name).toBe(newTeamName); + + // # Close modal immediately after save (should work without warning) + await teamSettings.close(); + + // * Verify modal closes without warning + await expect(teamSettings.container).not.toBeVisible(); + }); + + /** + * MM-TXXXX: Undo changes resets form state + * @objective Verify Undo button restores original values + */ + test('MM-TXXXX Undo changes resets form state', async ({pw}) => { + // # Set up admin user and login + const {adminUser, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + + // # Navigate to team + await channelsPage.goto(team.name); + await page.waitForLoadState('networkidle'); + + // # Open Team Settings Modal + const teamSettings = await channelsPage.openTeamSettings(); + + // # Edit team name + const newTeamName = `Modified Team ${await pw.random.id()}`; + await teamSettings.infoSettings.updateName(newTeamName); + + // * Verify input shows new value + await expect(teamSettings.infoSettings.nameInput).toHaveValue(newTeamName); + + // # Click Undo button + await teamSettings.undo(); + + // * Verify input restored to original value + await expect(teamSettings.infoSettings.nameInput).toHaveValue(team.display_name); + + // * Verify can close modal without warning + await teamSettings.close(); + await expect(teamSettings.container).not.toBeVisible(); + }); + + /** + * MM-TXXXX: Upload and Remove team icon + * @objective Verify team icon can be uploaded and removed + */ + test('MM-TXXXX Upload and Remove team icon', async ({pw}) => { + // # Set up admin user and login + const {adminUser, adminClient, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + + // # Navigate to team + await channelsPage.goto(team.name); + await page.waitForLoadState('networkidle'); + + // # Open Team Settings Modal + const teamSettings = await channelsPage.openTeamSettings(); + const infoSettings = teamSettings.infoSettings; + + // # Upload team icon using asset file + await infoSettings.uploadIcon(TEAM_ICON_ASSET); + + // * Verify upload preview shows + await expect(infoSettings.teamIconImage).toBeVisible(); + + // * Verify remove button appears + await expect(infoSettings.removeImageButton).toBeVisible(); + + // # Save changes + await teamSettings.save(); + await teamSettings.verifySavedMessage(); + + // * Get team data after upload to verify icon exists via API + const teamWithIcon = await adminClient.getTeam(team.id); + expect(teamWithIcon.last_team_icon_update).toBeGreaterThan(0); + + // # Close and reopen modal to verify persistence + await teamSettings.close(); + await expect(teamSettings.container).not.toBeVisible(); + const teamSettings2 = await channelsPage.openTeamSettings(); + + // * Verify uploaded icon persists after reopening modal + await expect(teamSettings2.infoSettings.teamIconImage).toBeVisible(); + await expect(teamSettings2.infoSettings.removeImageButton).toBeVisible(); + + // # Remove the icon + await teamSettings2.infoSettings.removeIcon(); + + // * Verify icon was removed - check for default icon initials in modal + await expect(teamSettings2.infoSettings.teamIconInitial).toBeVisible(); + + // * Verify icon was removed via API + const teamAfterRemove = await adminClient.getTeam(team.id); + expect(teamAfterRemove.last_team_icon_update || 0).toBe(0); + + // # Close modal + await teamSettings2.close(); + }); + + /** + * MM-TXXXX: Access tab - add and remove allowed domain + * @objective Verify allowed domains can be added and removed + */ + test('MM-TXXXX Access tab - add and remove allowed domain', async ({pw}) => { + // # Set up admin user and login + const {adminUser, adminClient, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + + // # Navigate to team + await channelsPage.goto(team.name); + await page.waitForLoadState('networkidle'); + + // # Open Team Settings Modal + const teamSettings = await channelsPage.openTeamSettings(); + + // # Switch to Access tab + const accessSettings = await teamSettings.openAccessTab(); + + // * Verify Access tab is active + await expect(teamSettings.accessTab).toHaveAttribute('aria-selected', 'true'); + + // # Enable allowed domains checkbox to show the input + await accessSettings.enableAllowedDomains(); + + // # Add an allowed domain + const testDomain = 'testdomain.com'; + await accessSettings.addDomain(testDomain); + + // * Verify domain appears in the UI + const domainChip = teamSettings.container.locator('#allowedDomains').getByText(testDomain); + await expect(domainChip).toBeVisible(); + + // # Save changes + await teamSettings.save(); + + // * Wait for "Settings saved" message + await teamSettings.verifySavedMessage(); + + // * Verify domain was saved via API + const updatedTeam = await adminClient.getTeam(team.id); + expect(updatedTeam.allowed_domains).toContain(testDomain); + + // # Remove the added domain + await accessSettings.removeDomain(testDomain); + + // # Save changes + await teamSettings.save(); + await teamSettings.verifySavedMessage(); + + // * Verify domain was removed via API + const finalTeam = await adminClient.getTeam(team.id); + expect(finalTeam.allowed_domains).not.toContain(testDomain); + + // # Close modal + await teamSettings.close(); + }); + + /** + * MM-TXXXX: Access tab - toggle allow open invite + * @objective Verify "Users on this server" setting can be toggled on/off + */ + test('MM-TXXXX Access tab - toggle allow open invite', async ({pw}) => { + // # Set up admin user and login + const {adminUser, adminClient, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + + // Get original allow_open_invite state + const originalTeam = await adminClient.getTeam(team.id); + const originalAllowOpenInvite = originalTeam.allow_open_invite ?? false; + + // # Navigate to team + await channelsPage.goto(team.name); + await page.waitForLoadState('networkidle'); + + // # Open Team Settings Modal + const teamSettings = await channelsPage.openTeamSettings(); + + // # Switch to Access tab + const accessSettings = await teamSettings.openAccessTab(); + + // * Verify Access tab is active + await expect(teamSettings.accessTab).toHaveAttribute('aria-selected', 'true'); + + // # Toggle allow open invite checkbox + await accessSettings.toggleOpenInvite(); + + // * Verify Save panel appears + await expect(teamSettings.saveButton).toBeVisible(); + + // # Save changes + await teamSettings.save(); + + // * Wait for "Settings saved" message + await teamSettings.verifySavedMessage(); + + // * Verify setting toggled via API + const updatedTeam = await adminClient.getTeam(team.id); + expect(updatedTeam.allow_open_invite).toBe(!originalAllowOpenInvite); + + // # Toggle back to original state + await accessSettings.toggleOpenInvite(); + + // # Save changes + await teamSettings.save(); + await teamSettings.verifySavedMessage(); + + // * Verify reverted to original state via API + const finalTeam = await adminClient.getTeam(team.id); + expect(finalTeam.allow_open_invite).toBe(originalAllowOpenInvite); + + // # Close modal + await teamSettings.close(); + }); + + /** + * MM-TXXXX: Access tab - regenerate invite ID + * @objective Verify team invite ID can be regenerated + */ + test('MM-TXXXX Access tab - regenerate invite ID', async ({pw}) => { + // # Set up admin user and login + const {adminUser, adminClient, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + + // Get original invite ID + const originalInviteId = team.invite_id; + + // # Navigate to team + await channelsPage.goto(team.name); + await page.waitForLoadState('networkidle'); + + // # Open Team Settings Modal + const teamSettings = await channelsPage.openTeamSettings(); + + // # Switch to Access tab + const accessSettings = await teamSettings.openAccessTab(); + + // * Verify Access tab is active + await expect(teamSettings.accessTab).toHaveAttribute('aria-selected', 'true'); + + // # Click regenerate button + await accessSettings.regenerateInviteId(); + + // * Verify invite ID changed via API + const updatedTeam = await adminClient.getTeam(team.id); + expect(updatedTeam.invite_id).not.toBe(originalInviteId); + expect(updatedTeam.invite_id).toBeTruthy(); + + // # Close modal + await teamSettings.close(); + }); +}); diff --git a/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/mobile_sidebar_right_items.tsx b/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/mobile_sidebar_right_items.tsx index 92f10897f8d..48cf7eb6b64 100644 --- a/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/mobile_sidebar_right_items.tsx +++ b/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/mobile_sidebar_right_items.tsx @@ -267,6 +267,9 @@ export class MobileSidebarRightItems extends React.PureComponent { id='teamSettings' modalId={ModalIdentifiers.TEAM_SETTINGS} dialogType={TeamSettingsModal} + dialogProps={{ + isOpen: true, + }} text={formatMessage({id: 'navbar_dropdown.teamSettings', defaultMessage: 'Team Settings'})} icon={ void; - setHasChangeTabError: (hasChangesError: boolean) => void; - setJustSaved: (justSaved: boolean) => void; - closeModal: () => void; - collapseModal: () => void; + areThereUnsavedChanges: boolean; + showTabSwitchError: boolean; + setAreThereUnsavedChanges: (unsaved: boolean) => void; + setShowTabSwitchError: (error: boolean) => void; }; function mapDispatchToProps(dispatch: Dispatch) { diff --git a/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.test.tsx b/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.test.tsx index d60d42a3743..406987acf3f 100644 --- a/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.test.tsx +++ b/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.test.tsx @@ -26,14 +26,11 @@ describe('components/TeamSettings', () => { }; const defaultProps: ComponentProps = { team: TestHelper.getTeamMock({id: 'team_id'}), - closeModal: jest.fn(), actions: baseActions, - hasChanges: true, - hasChangeTabError: false, - setHasChanges: jest.fn(), - setHasChangeTabError: jest.fn(), - setJustSaved: jest.fn(), - collapseModal: jest.fn(), + areThereUnsavedChanges: true, + showTabSwitchError: false, + setAreThereUnsavedChanges: jest.fn(), + setShowTabSwitchError: jest.fn(), }; test('should not render team invite section if no permissions for team inviting', () => { diff --git a/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.tsx b/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.tsx index 96cd7a44822..05f6d9ab6f6 100644 --- a/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.tsx +++ b/webapp/channels/src/components/team_settings/team_access_tab/team_access_tab.tsx @@ -2,9 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback, useState} from 'react'; -import {useIntl} from 'react-intl'; -import ModalSection from 'components/widgets/modals/components/modal_section'; import SaveChangesPanel, {type SaveChangesPanelState} from 'components/widgets/modals/components/save_changes_panel'; import AllowedDomainsSelect from './allowed_domains_select'; @@ -25,11 +23,10 @@ const generateAllowedDomainOptions = (allowedDomains?: string) => { type Props = PropsFromRedux & OwnProps; -const AccessTab = ({closeModal, collapseModal, hasChangeTabError, hasChanges, setHasChangeTabError, setHasChanges, setJustSaved, team, actions}: Props) => { +const AccessTab = ({showTabSwitchError, areThereUnsavedChanges, setShowTabSwitchError, setAreThereUnsavedChanges, team, actions}: Props) => { const [allowedDomains, setAllowedDomains] = useState(() => generateAllowedDomainOptions(team.allowed_domains)); const [allowOpenInvite, setAllowOpenInvite] = useState(team.allow_open_invite ?? false); const [saveChangesPanelState, setSaveChangesPanelState] = useState(); - const {formatMessage} = useIntl(); const handleAllowedDomainsSubmit = useCallback(async (): Promise => { const {error} = await actions.patchTeam({ @@ -59,17 +56,16 @@ const AccessTab = ({closeModal, collapseModal, hasChangeTabError, hasChanges, se }, [actions, allowOpenInvite, team]); const updateOpenInvite = useCallback((value: boolean) => { - setHasChanges(true); + setAreThereUnsavedChanges(true); setSaveChangesPanelState('editing'); setAllowOpenInvite(value); - }, [setHasChanges]); + }, [setAreThereUnsavedChanges]); const handleClose = useCallback(() => { setSaveChangesPanelState('editing'); - setHasChanges(false); - setHasChangeTabError(false); - setJustSaved(false); // Reset flag when panel closes - }, [setHasChangeTabError, setHasChanges, setJustSaved]); + setAreThereUnsavedChanges(false); + setShowTabSwitchError(false); + }, [setShowTabSwitchError, setAreThereUnsavedChanges]); const handleCancel = useCallback(() => { setAllowedDomains(generateAllowedDomainOptions(team.allowed_domains)); @@ -77,14 +73,6 @@ const AccessTab = ({closeModal, collapseModal, hasChangeTabError, hasChanges, se handleClose(); }, [handleClose, team.allow_open_invite, team.allowed_domains]); - const collapseModalHandler = useCallback(() => { - if (hasChanges) { - setHasChangeTabError(true); - return; - } - collapseModal(); - }, [collapseModal, hasChanges, setHasChangeTabError]); - const handleSaveChanges = useCallback(async () => { const allowedDomainSuccess = await handleAllowedDomainsSubmit(); const openInviteSuccess = await handleOpenInviteSubmit(); @@ -93,75 +81,48 @@ const AccessTab = ({closeModal, collapseModal, hasChangeTabError, hasChanges, se return; } setSaveChangesPanelState('saved'); - setHasChangeTabError(false); - setJustSaved(true); // Flag that save just completed - }, [handleAllowedDomainsSubmit, handleOpenInviteSubmit, setHasChangeTabError, setJustSaved]); + setShowTabSwitchError(false); + + // allows modal to close immediately + setAreThereUnsavedChanges(false); + }, [handleAllowedDomainsSubmit, handleOpenInviteSubmit, setShowTabSwitchError, setAreThereUnsavedChanges]); return ( - -
- -

-
- -
- {formatMessage({id: 'team_settings_modal.title', defaultMessage: 'Team Settings'})} -

-
-
- {!team.group_constrained && ( - - )} -
- -
- {!team.group_constrained && ( - - )} - {hasChanges && ( - - )} -
- - } - /> +
+ {!team.group_constrained && ( + + )} +
+ +
+ {!team.group_constrained && ( + + )} + {(areThereUnsavedChanges || saveChangesPanelState === 'saved') && ( + + )} +
); }; + export default AccessTab; diff --git a/webapp/channels/src/components/team_settings/team_info_tab/index.ts b/webapp/channels/src/components/team_settings/team_info_tab/index.ts index 1e9b35bffb3..be4880d807f 100644 --- a/webapp/channels/src/components/team_settings/team_info_tab/index.ts +++ b/webapp/channels/src/components/team_settings/team_info_tab/index.ts @@ -17,13 +17,10 @@ import TeamInfoTab from './team_info_tab'; export type OwnProps = { team: Team; - hasChanges: boolean; - hasChangeTabError: boolean; - setHasChanges: (hasChanges: boolean) => void; - setHasChangeTabError: (hasChangesError: boolean) => void; - setJustSaved: (justSaved: boolean) => void; - closeModal: () => void; - collapseModal: () => void; + areThereUnsavedChanges: boolean; + showTabSwitchError: boolean; + setAreThereUnsavedChanges: (unsaved: boolean) => void; + setShowTabSwitchError: (error: boolean) => void; }; function mapStateToProps(state: GlobalState) { diff --git a/webapp/channels/src/components/team_settings/team_info_tab/team_info_tab.test.tsx b/webapp/channels/src/components/team_settings/team_info_tab/team_info_tab.test.tsx index 035b6c0cd1b..d92febbe773 100644 --- a/webapp/channels/src/components/team_settings/team_info_tab/team_info_tab.test.tsx +++ b/webapp/channels/src/components/team_settings/team_info_tab/team_info_tab.test.tsx @@ -26,13 +26,10 @@ describe('components/TeamSettings', () => { team: TestHelper.getTeamMock({id: 'team_id', name: 'team_name', display_name: 'team_display_name', description: 'team_description'}), maxFileSize: 50, actions: baseActions, - hasChanges: true, - hasChangeTabError: false, - setHasChanges: jest.fn(), - setHasChangeTabError: jest.fn(), - setJustSaved: jest.fn(), - closeModal: jest.fn(), - collapseModal: jest.fn(), + areThereUnsavedChanges: true, + showTabSwitchError: false, + setAreThereUnsavedChanges: jest.fn(), + setShowTabSwitchError: jest.fn(), }; beforeEach(() => { diff --git a/webapp/channels/src/components/team_settings/team_info_tab/team_info_tab.tsx b/webapp/channels/src/components/team_settings/team_info_tab/team_info_tab.tsx index 373202d2f38..3ef288fa654 100644 --- a/webapp/channels/src/components/team_settings/team_info_tab/team_info_tab.tsx +++ b/webapp/channels/src/components/team_settings/team_info_tab/team_info_tab.tsx @@ -3,12 +3,11 @@ import React, {useCallback, useState} from 'react'; import type {ChangeEvent} from 'react'; -import {defineMessages, useIntl} from 'react-intl'; +import {defineMessages} from 'react-intl'; import type {Team} from '@mattermost/types/teams'; import type {BaseSettingItemProps} from 'components/widgets/modals/components/base_setting_item'; -import ModalSection from 'components/widgets/modals/components/modal_section'; import SaveChangesPanel, {type SaveChangesPanelState} from 'components/widgets/modals/components/save_changes_panel'; import Constants from 'utils/constants'; @@ -45,9 +44,10 @@ const translations = defineMessages({ defaultMessage: 'An error occurred while selecting the image.', }, }); + type Props = PropsFromRedux & OwnProps; -const InfoTab = ({team, hasChanges, maxFileSize, closeModal, collapseModal, hasChangeTabError, setHasChangeTabError, setHasChanges, setJustSaved, actions}: Props) => { +const InfoTab = ({team, areThereUnsavedChanges, maxFileSize, showTabSwitchError, setShowTabSwitchError, setAreThereUnsavedChanges, actions}: Props) => { const [name, setName] = useState(team.display_name); const [description, setDescription] = useState(team.description); const [teamIconFile, setTeamIconFile] = useState(); @@ -55,7 +55,6 @@ const InfoTab = ({team, hasChanges, maxFileSize, closeModal, collapseModal, hasC const [imageClientError, setImageClientError] = useState(); const [nameClientError, setNameClientError] = useState(); const [saveChangesPanelState, setSaveChangesPanelState] = useState(); - const {formatMessage} = useIntl(); const handleNameDescriptionSubmit = useCallback(async (): Promise => { if (name.trim() === team.display_name && description === team.description) { @@ -99,16 +98,16 @@ const InfoTab = ({team, hasChanges, maxFileSize, closeModal, collapseModal, hasC return; } setSaveChangesPanelState('saved'); - setHasChangeTabError(false); - setJustSaved(true); // Flag that save just completed - }, [handleNameDescriptionSubmit, handleTeamIconSubmit, setHasChangeTabError, setJustSaved]); + setShowTabSwitchError(false); + + setAreThereUnsavedChanges(false); + }, [handleNameDescriptionSubmit, handleTeamIconSubmit, setShowTabSwitchError, setAreThereUnsavedChanges]); const handleClose = useCallback(() => { setSaveChangesPanelState('editing'); - setHasChanges(false); - setHasChangeTabError(false); - setJustSaved(false); // Reset flag when panel closes - }, [setHasChangeTabError, setHasChanges, setJustSaved]); + setAreThereUnsavedChanges(false); + setShowTabSwitchError(false); + }, [setShowTabSwitchError, setAreThereUnsavedChanges]); const handleCancel = useCallback(() => { setName(team.display_name ?? team.name); @@ -129,10 +128,10 @@ const InfoTab = ({team, hasChanges, maxFileSize, closeModal, collapseModal, hasC setLoading(false); if (error) { setSaveChangesPanelState('error'); - setHasChanges(true); - setHasChangeTabError(true); + setAreThereUnsavedChanges(true); + setShowTabSwitchError(true); } - }, [actions, handleClose, setHasChangeTabError, setHasChanges, team.id]); + }, [actions, handleClose, setShowTabSwitchError, setAreThereUnsavedChanges, team.id]); const updateTeamIcon = useCallback((e: ChangeEvent) => { if (e && e.target && e.target.files && e.target.files[0]) { @@ -146,99 +145,64 @@ const InfoTab = ({team, hasChanges, maxFileSize, closeModal, collapseModal, hasC setTeamIconFile(file); setImageClientError(undefined); setSaveChangesPanelState('editing'); - setHasChanges(true); + setAreThereUnsavedChanges(true); } } else { setTeamIconFile(undefined); setImageClientError(translations.TeamIconError); } - }, [maxFileSize, setHasChanges]); + }, [maxFileSize, setAreThereUnsavedChanges]); const handleNameChanges = useCallback((name: string) => { - setHasChanges(true); + setAreThereUnsavedChanges(true); setSaveChangesPanelState('editing'); setName(name); - }, [setHasChanges]); + }, [setAreThereUnsavedChanges]); const handleDescriptionChanges = useCallback((description: string) => { - setHasChanges(true); + setAreThereUnsavedChanges(true); setSaveChangesPanelState('editing'); setDescription(description); - }, [setHasChanges]); + }, [setAreThereUnsavedChanges]); - const handleCollapseModal = useCallback(() => { - if (hasChanges) { - setHasChangeTabError(true); - return; - } - collapseModal(); - }, [collapseModal, hasChanges, setHasChangeTabError]); - - const modalSectionContent = ( - <> -
- -

-
- -
- {formatMessage({id: 'team_settings_modal.title', defaultMessage: 'Team Settings'})} -

-
-
-
- - -
- +
+ + - {hasChanges && ( - - )}
- + + {(areThereUnsavedChanges || saveChangesPanelState === 'saved') && ( + + )} +
); - - return ; }; + export default InfoTab; diff --git a/webapp/channels/src/components/team_settings/team_settings.tsx b/webapp/channels/src/components/team_settings/team_settings.tsx index 91aa4aefcc9..f3787c89079 100644 --- a/webapp/channels/src/components/team_settings/team_settings.tsx +++ b/webapp/channels/src/components/team_settings/team_settings.tsx @@ -10,26 +10,20 @@ import InfoTab from './team_info_tab'; type Props = { activeTab: string; - hasChanges: boolean; - hasChangeTabError: boolean; - setHasChanges: (hasChanges: boolean) => void; - setHasChangeTabError: (hasChangesError: boolean) => void; - setJustSaved: (justSaved: boolean) => void; - closeModal: () => void; - collapseModal: () => void; + areThereUnsavedChanges: boolean; + showTabSwitchError: boolean; + setAreThereUnsavedChanges: (unsaved: boolean) => void; + setShowTabSwitchError: (error: boolean) => void; team?: Team; }; const TeamSettings = ({ activeTab = '', - closeModal, - collapseModal, team, - hasChanges, - hasChangeTabError, - setHasChanges, - setHasChangeTabError, - setJustSaved, + areThereUnsavedChanges, + showTabSwitchError, + setAreThereUnsavedChanges, + setShowTabSwitchError, }: Props) => { if (!team) { return null; @@ -41,13 +35,10 @@ const TeamSettings = ({ result = ( ); break; @@ -55,13 +46,10 @@ const TeamSettings = ({ result = ( ); break; diff --git a/webapp/channels/src/components/team_settings_modal/index.ts b/webapp/channels/src/components/team_settings_modal/index.ts index 46e51c41736..00ce5d204ec 100644 --- a/webapp/channels/src/components/team_settings_modal/index.ts +++ b/webapp/channels/src/components/team_settings_modal/index.ts @@ -1,28 +1,4 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {connect} from 'react-redux'; - -import {Permissions} from 'mattermost-redux/constants'; -import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles'; -import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; - -import {isModalOpen} from 'selectors/views/modals'; - -import {ModalIdentifiers} from 'utils/constants'; - -import type {GlobalState} from 'types/store'; - -import TeamSettingsModal from './team_settings_modal'; - -function mapStateToProps(state: GlobalState) { - const teamId = getCurrentTeamId(state); - const canInviteUsers = haveITeamPermission(state, teamId, Permissions.INVITE_USER); - const modalId = ModalIdentifiers.TEAM_SETTINGS; - return { - show: isModalOpen(state, modalId), - canInviteUsers, - }; -} - -export default connect(mapStateToProps)(TeamSettingsModal); +export {default} from './team_settings_modal'; diff --git a/webapp/channels/src/components/team_settings_modal/team_settings_modal.scss b/webapp/channels/src/components/team_settings_modal/team_settings_modal.scss new file mode 100644 index 00000000000..0c018a73fb9 --- /dev/null +++ b/webapp/channels/src/components/team_settings_modal/team_settings_modal.scss @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.TeamSettingsModal { + width: 920px !important; + + // Override GenericModal wrapper styles + .GenericModal__wrapper { + display: flex; + overflow: visible; + max-width: 1200px; + max-height: 90vh; + flex-direction: column; + border: var(--border-default); + border-radius: var(--radius-l); + box-shadow: var(--elevation-6); + + .modal-body { + display: flex; + width: auto; + min-height: 150px; + flex-direction: column; + margin: 0; + gap: 24px; + overflow-y: auto; + } + } + + &__bodyWrapper { + display: flex; + width: 100%; + max-width: 920px; + flex-direction: column; + gap: 24px; + } + + // Settings table layout (inherits most from global styles) + .settings-table { + display: flex; + flex-direction: row; + + .settings-content { + display: flex; + overflow: visible !important; + flex: 1; + flex-direction: column; + padding: 0 32px; + } + } + + .modal-body .form-control { + border: none !important; + } + + // SaveChangesPanel width override + .SaveChangesPanel { + width: calc(70%); + + @media screen and (max-width: 768px) { + width: calc(75%); + } + } + + // Responsive behavior + @media screen and (max-width: 768px) { + max-width: 100%; + margin: 0; + + .modal-content { + display: flex; + height: 100vh; + max-height: unset; + flex-direction: column; + border-radius: unset; + } + } + + @media screen and (max-height: 900px) and (min-width: 768px) { + .modal-content { + max-height: 90vh; + } + } + + @media screen and (max-height: 600px) { + .modal-content, + .GenericModal__wrapper { + max-height: 85vh !important; + } + } +} diff --git a/webapp/channels/src/components/team_settings_modal/team_settings_modal.test.tsx b/webapp/channels/src/components/team_settings_modal/team_settings_modal.test.tsx index 1e5dcda85a5..0289fb27a8d 100644 --- a/webapp/channels/src/components/team_settings_modal/team_settings_modal.test.tsx +++ b/webapp/channels/src/components/team_settings_modal/team_settings_modal.test.tsx @@ -3,14 +3,63 @@ import React from 'react'; +import {Permissions} from 'mattermost-redux/constants'; + import TeamSettingsModal from 'components/team_settings_modal/team_settings_modal'; import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; +// Mock Redux actions +jest.mock('mattermost-redux/actions/teams', () => ({ + patchTeam: jest.fn(() => async () => ({data: {}, error: null})), + getTeam: jest.fn(() => async () => ({data: {}, error: null})), + removeTeamIcon: jest.fn(() => async () => ({data: {}, error: null})), + setTeamIcon: jest.fn(() => async () => ({data: {}, error: null})), +})); + describe('components/team_settings_modal', () => { const baseProps = { + isOpen: true, onExited: jest.fn(), - canInviteUsers: true, + }; + + const baseState = { + entities: { + teams: { + currentTeamId: 'team-id', + teams: { + 'team-id': { + id: 'team-id', + display_name: 'Team Name', + description: 'Team Description', + name: 'team-name', + }, + }, + myMembers: { + 'team-id': { + team_id: 'team-id', + user_id: 'user-id', + roles: 'team_user', + }, + }, + }, + roles: { + roles: { + team_user: { + permissions: [Permissions.INVITE_USER], + }, + }, + }, + users: { + currentUserId: 'user-id', + profiles: { + 'user-id': { + id: 'user-id', + roles: 'team_user', + }, + }, + }, + }, }; test('should hide the modal when the close button is clicked', async () => { @@ -18,19 +67,24 @@ describe('components/team_settings_modal', () => { , + baseState, ); - const modal = screen.getByRole('dialog', {name: 'Close Team Settings'}); - expect(modal.className).toBe('fade in modal'); - await userEvent.click(screen.getByText('Close')); - expect(modal.className).toBe('fade modal'); + const modal = screen.getByRole('dialog', {name: 'Team Settings'}); + expect(modal).toBeInTheDocument(); + const closeButton = screen.getByLabelText('Close'); + await userEvent.click(closeButton); + + await waitFor(() => { + expect(baseProps.onExited).toHaveBeenCalled(); + }); }); test('should display access tab when can invite users', async () => { - const props = {...baseProps, canInviteUsers: true}; renderWithContext( , + baseState, ); const infoButton = screen.getByRole('tab', {name: 'info'}); expect(infoButton).toBeDefined(); @@ -39,11 +93,25 @@ describe('components/team_settings_modal', () => { }); test('should not display access tab when can not invite users', async () => { - const props = {...baseProps, canInviteUsers: false}; + const stateWithoutPermission = { + ...baseState, + entities: { + ...baseState.entities, + roles: { + roles: { + team_user: { + permissions: [], + }, + }, + }, + }, + }; + renderWithContext( , + stateWithoutPermission, ); const tabs = screen.getAllByRole('tab'); expect(tabs.length).toEqual(1); @@ -56,23 +124,10 @@ describe('components/team_settings_modal', () => { , - { - entities: { - teams: { - currentTeamId: 'team-id', - teams: { - 'team-id': { - id: 'team-id', - display_name: 'Team Name', - description: 'Team Description', - }, - }, - }, - }, - }, + baseState, ); - const modal = screen.getByRole('dialog', {name: 'Close Team Settings'}); + const modal = screen.getByRole('dialog', {name: 'Team Settings'}); expect(modal).toBeInTheDocument(); // Create unsaved changes by modifying team name @@ -100,20 +155,7 @@ describe('components/team_settings_modal', () => { , - { - entities: { - teams: { - currentTeamId: 'team-id', - teams: { - 'team-id': { - id: 'team-id', - display_name: 'Team Name', - description: 'Team Description', - }, - }, - }, - }, - }, + baseState, ); // Create unsaved changes @@ -143,23 +185,10 @@ describe('components/team_settings_modal', () => { , - { - entities: { - teams: { - currentTeamId: 'team-id', - teams: { - 'team-id': { - id: 'team-id', - display_name: 'Team Name', - description: 'Team Description', - }, - }, - }, - }, - }, + baseState, ); - const modal = screen.getByRole('dialog', {name: 'Close Team Settings'}); + const modal = screen.getByRole('dialog', {name: 'Team Settings'}); expect(modal).toBeInTheDocument(); // Close modal with no unsaved changes @@ -177,20 +206,7 @@ describe('components/team_settings_modal', () => { , - { - entities: { - teams: { - currentTeamId: 'team-id', - teams: { - 'team-id': { - id: 'team-id', - display_name: 'Team Name', - description: 'Team Description', - }, - }, - }, - }, - }, + baseState, ); // Create unsaved changes @@ -198,24 +214,22 @@ describe('components/team_settings_modal', () => { await userEvent.clear(nameInput); await userEvent.type(nameInput, 'Modified Team Name'); - const closeButton = screen.getByLabelText('Close'); - - // Trigger warning by attempting to close - await userEvent.click(closeButton); - - expect(screen.getByText('You have unsaved changes')).toBeInTheDocument(); - - // Save changes to reset warning state + // Save changes immediately (without triggering warning first) const saveButton = screen.getByText('Save'); await userEvent.click(saveButton); - // Close modal after saving + // Wait for save to complete and "Settings saved" message + await waitFor(() => { + expect(screen.getByText('Settings saved')).toBeInTheDocument(); + }); + + // After saving, close modal - should work immediately (single click) + const closeButton = screen.getByLabelText('Close'); await userEvent.click(closeButton); - // Verify modal closes successfully + // Verify modal closes successfully without warning await waitFor(() => { expect(baseProps.onExited).toHaveBeenCalled(); }); }); }); - diff --git a/webapp/channels/src/components/team_settings_modal/team_settings_modal.tsx b/webapp/channels/src/components/team_settings_modal/team_settings_modal.tsx index 77361515573..35c61bb539f 100644 --- a/webapp/channels/src/components/team_settings_modal/team_settings_modal.tsx +++ b/webapp/channels/src/components/team_settings_modal/team_settings_modal.tsx @@ -1,108 +1,138 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useState, useRef, useCallback} from 'react'; -import {Modal, type ModalBody} from 'react-bootstrap'; -import ReactDOM from 'react-dom'; +import React, {useState, useRef, useCallback, useEffect} from 'react'; import {useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {GenericModal} from '@mattermost/components'; + +import {Permissions} from 'mattermost-redux/constants'; +import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import TeamSettings from 'components/team_settings'; import {focusElement} from 'utils/a11y_utils'; +import type {GlobalState} from 'types/store'; + +import './team_settings_modal.scss'; + const SettingsSidebar = React.lazy(() => import('components/settings_sidebar')); const SHOW_PANEL_ERROR_STATE_TAB_SWITCH_TIMEOUT = 3000; type Props = { + isOpen: boolean; onExited: () => void; - canInviteUsers: boolean; focusOriginElement?: string; } -const TeamSettingsModal = ({onExited, canInviteUsers, focusOriginElement}: Props) => { +const TeamSettingsModal = ({isOpen, onExited, focusOriginElement}: Props) => { const [activeTab, setActiveTab] = useState('info'); - const [show, setShow] = useState(true); - const [hasChanges, setHasChanges] = useState(false); - const [hasChangeTabError, setHasChangeTabError] = useState(false); - const [hasBeenWarned, setHasBeenWarned] = useState(false); - const [justSaved, setJustSaved] = useState(false); - const modalBodyRef = useRef(null); + const [show, setShow] = useState(isOpen); + const [areThereUnsavedChanges, setAreThereUnsavedChanges] = useState(false); + const [showTabSwitchError, setShowTabSwitchError] = useState(false); + const [hasBeenWarned, setHasBeenWarned] = useState(false); + const modalBodyRef = useRef(null); const {formatMessage} = useIntl(); + const teamId = useSelector(getCurrentTeamId); + const canInviteUsers = useSelector((state: GlobalState) => + haveITeamPermission(state, teamId, Permissions.INVITE_USER), + ); + + useEffect(() => { + setShow(isOpen); + }, [isOpen]); + const updateTab = useCallback((tab: string) => { - if (hasChanges) { - setHasChangeTabError(true); + if (areThereUnsavedChanges) { + setShowTabSwitchError(true); + setTimeout(() => { + setShowTabSwitchError(false); + }, SHOW_PANEL_ERROR_STATE_TAB_SWITCH_TIMEOUT); return; } setActiveTab(tab); - setHasChanges(false); - setHasChangeTabError(false); - setHasBeenWarned(false); - }, [hasChanges]); + + if (modalBodyRef.current) { + modalBodyRef.current.scrollTop = 0; + } + }, [areThereUnsavedChanges]); const handleHide = useCallback(() => { // Prevent modal closing if there are unsaved changes (warn once, then allow) - // Don't warn if showing "Settings saved" - if (hasChanges && !hasBeenWarned && !justSaved) { + if (areThereUnsavedChanges && !hasBeenWarned) { setHasBeenWarned(true); - setHasChangeTabError(true); + setShowTabSwitchError(true); setTimeout(() => { - setHasChangeTabError(false); + setShowTabSwitchError(false); }, SHOW_PANEL_ERROR_STATE_TAB_SWITCH_TIMEOUT); } else { - setShow(false); + handleHideConfirm(); } - }, [hasChanges, hasBeenWarned, justSaved]); + }, [areThereUnsavedChanges, hasBeenWarned]); - const handleClose = useCallback(() => { + const handleHideConfirm = useCallback(() => { + setShow(false); + }, []); + + const handleExited = useCallback(() => { + // Reset all state + setActiveTab('info'); + setAreThereUnsavedChanges(false); + setShowTabSwitchError(false); + setHasBeenWarned(false); + + // Restore focus if (focusOriginElement) { focusElement(focusOriginElement, true); } - setActiveTab('info'); - setHasChanges(false); - setHasChangeTabError(false); - setHasBeenWarned(false); - setJustSaved(false); + + // Notify parent onExited(); }, [onExited, focusOriginElement]); - const handleCollapse = useCallback(() => { - const el = ReactDOM.findDOMNode(modalBodyRef.current) as HTMLDivElement; - el?.closest('.modal-dialog')!.classList.remove('display--content'); - setActiveTab(''); - }, []); - const tabs = [ - {name: 'info', uiName: formatMessage({id: 'team_settings_modal.infoTab', defaultMessage: 'Info'}), icon: 'icon icon-information-outline', iconTitle: formatMessage({id: 'generic_icons.info', defaultMessage: 'Info Icon'})}, + { + name: 'info', + uiName: formatMessage({id: 'team_settings_modal.infoTab', defaultMessage: 'Info'}), + icon: 'icon icon-information-outline', + iconTitle: formatMessage({id: 'generic_icons.info', defaultMessage: 'Info Icon'}), + }, + { + name: 'access', + uiName: formatMessage({id: 'team_settings_modal.accessTab', defaultMessage: 'Access'}), + icon: 'icon icon-account-multiple-outline', + iconTitle: formatMessage({id: 'generic_icons.member', defaultMessage: 'Member Icon'}), + display: canInviteUsers, + }, ]; - if (canInviteUsers) { - tabs.push({name: 'access', uiName: formatMessage({id: 'team_settings_modal.accessTab', defaultMessage: 'Access'}), icon: 'icon icon-account-multiple-outline', iconTitle: formatMessage({id: 'generic_icons.member', defaultMessage: 'Member Icon'})}); - } + + const modalTitle = formatMessage({id: 'team_settings_modal.title', defaultMessage: 'Team Settings'}); return ( - - - +
- {formatMessage({id: 'team_settings_modal.title', defaultMessage: 'Team Settings'})} - - - -
-
- +
+ ); };