Mm 65975 - migrate team modal to generic modal (#35256)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions

* 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
This commit is contained in:
Pablo Vélez 2026-02-17 17:40:29 -05:00 committed by GitHub
parent 2da1e56e6c
commit cef5134865
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1096 additions and 409 deletions

View file

@ -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

View file

@ -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

View file

@ -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();
});
};

View file

@ -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

View file

@ -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

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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<InfoSettings> {
await expect(this.infoTab).toBeVisible();
await this.infoTab.click();
return this.infoSettings;
}
async openAccessTab(): Promise<AccessSettings> {
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});
}
}

View file

@ -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<TeamSettingsModal> {
await this.page.locator('#sidebarTeamMenuButton').click();
await this.page.getByText('Team settings').first().click();
await this.teamSettingsModal.toBeVisible();
return this.teamSettingsModal;
}
async openChannelSettings(): Promise<ChannelSettingsModal> {
await this.centerView.header.openChannelMenu();
await this.page.locator('#channelSettings[role="menuitem"]').click();

View file

@ -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();
});
});

View file

@ -267,6 +267,9 @@ export class MobileSidebarRightItems extends React.PureComponent<Props> {
id='teamSettings'
modalId={ModalIdentifiers.TEAM_SETTINGS}
dialogType={TeamSettingsModal}
dialogProps={{
isOpen: true,
}}
text={formatMessage({id: 'navbar_dropdown.teamSettings', defaultMessage: 'Team Settings'})}
icon={
<i

View file

@ -203,6 +203,7 @@ function TeamSettingsMenuItem(props: Menu.FirstMenuItemProps) {
modalId: ModalIdentifiers.TEAM_SETTINGS,
dialogType: TeamSettingsModal,
dialogProps: {
isOpen: true,
focusOriginElement: 'sidebarTeamMenuButton',
},
}));

View file

@ -14,13 +14,10 @@ import TeamAccessTab from './team_access_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 mapDispatchToProps(dispatch: Dispatch) {

View file

@ -26,14 +26,11 @@ describe('components/TeamSettings', () => {
};
const defaultProps: ComponentProps<typeof AccessTab> = {
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', () => {

View file

@ -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<string[]>(() => generateAllowedDomainOptions(team.allowed_domains));
const [allowOpenInvite, setAllowOpenInvite] = useState<boolean>(team.allow_open_invite ?? false);
const [saveChangesPanelState, setSaveChangesPanelState] = useState<SaveChangesPanelState>();
const {formatMessage} = useIntl();
const handleAllowedDomainsSubmit = useCallback(async (): Promise<boolean> => {
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 (
<ModalSection
content={
<>
<div className='modal-header'>
<button
id='closeButton'
type='button'
className='close'
data-dismiss='modal'
onClick={closeModal}
>
<span aria-hidden='true'>{'×'}</span>
</button>
<h4 className='modal-title'>
<div className='modal-back'>
<i
className='fa fa-angle-left'
aria-label={formatMessage({
id: 'generic_icons.collapse',
defaultMessage: 'Collapse Icon',
})}
onClick={collapseModalHandler}
/>
</div>
<span>{formatMessage({id: 'team_settings_modal.title', defaultMessage: 'Team Settings'})}</span>
</h4>
</div>
<div
className='modal-access-tab-content user-settings'
id='accessSettings'
aria-labelledby='accessButton'
role='tabpanel'
>
{!team.group_constrained && (
<AllowedDomainsSelect
allowedDomains={allowedDomains}
setAllowedDomains={setAllowedDomains}
setHasChanges={setHasChanges}
setSaveChangesPanelState={setSaveChangesPanelState}
/>
)}
<div className='divider-light'/>
<OpenInvite
isGroupConstrained={team.group_constrained}
allowOpenInvite={allowOpenInvite}
setAllowOpenInvite={updateOpenInvite}
/>
<div className='divider-light'/>
{!team.group_constrained && (
<InviteSectionInput regenerateTeamInviteId={actions.regenerateTeamInviteId}/>
)}
{hasChanges && (
<SaveChangesPanel
handleCancel={handleCancel}
handleSubmit={handleSaveChanges}
handleClose={handleClose}
tabChangeError={hasChangeTabError}
state={saveChangesPanelState}
/>
)}
</div>
</>
}
/>
<div
className='modal-access-tab-content user-settings'
id='accessSettings'
aria-labelledby='accessButton'
role='tabpanel'
>
{!team.group_constrained && (
<AllowedDomainsSelect
allowedDomains={allowedDomains}
setAllowedDomains={setAllowedDomains}
setHasChanges={setAreThereUnsavedChanges}
setSaveChangesPanelState={setSaveChangesPanelState}
/>
)}
<div className='divider-light'/>
<OpenInvite
isGroupConstrained={team.group_constrained}
allowOpenInvite={allowOpenInvite}
setAllowOpenInvite={updateOpenInvite}
/>
<div className='divider-light'/>
{!team.group_constrained && (
<InviteSectionInput regenerateTeamInviteId={actions.regenerateTeamInviteId}/>
)}
{(areThereUnsavedChanges || saveChangesPanelState === 'saved') && (
<SaveChangesPanel
handleCancel={handleCancel}
handleSubmit={handleSaveChanges}
handleClose={handleClose}
tabChangeError={showTabSwitchError}
state={saveChangesPanelState}
/>
)}
</div>
);
};
export default AccessTab;

View file

@ -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) {

View file

@ -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(() => {

View file

@ -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']>(team.display_name);
const [description, setDescription] = useState<Team['description']>(team.description);
const [teamIconFile, setTeamIconFile] = useState<File | undefined>();
@ -55,7 +55,6 @@ const InfoTab = ({team, hasChanges, maxFileSize, closeModal, collapseModal, hasC
const [imageClientError, setImageClientError] = useState<BaseSettingItemProps['error'] | undefined>();
const [nameClientError, setNameClientError] = useState<BaseSettingItemProps['error'] | undefined>();
const [saveChangesPanelState, setSaveChangesPanelState] = useState<SaveChangesPanelState>();
const {formatMessage} = useIntl();
const handleNameDescriptionSubmit = useCallback(async (): Promise<boolean> => {
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<HTMLInputElement>) => {
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 = (
<>
<div className='modal-header'>
<button
id='closeButton'
type='button'
className='close'
data-dismiss='modal'
onClick={closeModal}
>
<span aria-hidden='true'>{'×'}</span>
</button>
<h4 className='modal-title'>
<div className='modal-back'>
<i
className='fa fa-angle-left'
aria-label={formatMessage({
id: 'generic_icons.collapse',
defaultMessage: 'Collapse Icon',
})}
onClick={handleCollapseModal}
/>
</div>
<span>{formatMessage({id: 'team_settings_modal.title', defaultMessage: 'Team Settings'})}</span>
</h4>
</div>
<div
className='modal-info-tab-content user-settings'
id='infoSettings'
aria-labelledby='infoButton'
role='tabpanel'
>
<div className='name-description-container' >
<TeamNameSection
name={name}
clientError={nameClientError}
handleNameChanges={handleNameChanges}
/>
<TeamDescriptionSection
description={description}
handleDescriptionChanges={handleDescriptionChanges}
/>
</div>
<TeamPictureSection
team={team}
file={teamIconFile}
disabled={loading}
onFileChange={updateTeamIcon}
onRemove={handleTeamIconRemove}
teamName={team.display_name ?? team.name}
clientError={imageClientError}
return (
<div
className='modal-info-tab-content user-settings'
id='infoSettings'
aria-labelledby='infoButton'
role='tabpanel'
>
<div className='name-description-container'>
<TeamNameSection
name={name}
clientError={nameClientError}
handleNameChanges={handleNameChanges}
/>
<TeamDescriptionSection
description={description}
handleDescriptionChanges={handleDescriptionChanges}
/>
{hasChanges && (
<SaveChangesPanel
handleCancel={handleCancel}
handleSubmit={handleSaveChanges}
handleClose={handleClose}
tabChangeError={hasChangeTabError}
state={saveChangesPanelState}
/>
)}
</div>
</>
<TeamPictureSection
team={team}
file={teamIconFile}
disabled={loading}
onFileChange={updateTeamIcon}
onRemove={handleTeamIconRemove}
teamName={team.display_name ?? team.name}
clientError={imageClientError}
/>
{(areThereUnsavedChanges || saveChangesPanelState === 'saved') && (
<SaveChangesPanel
handleCancel={handleCancel}
handleSubmit={handleSaveChanges}
handleClose={handleClose}
tabChangeError={showTabSwitchError}
state={saveChangesPanelState}
/>
)}
</div>
);
return <ModalSection content={modalSectionContent}/>;
};
export default InfoTab;

View file

@ -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 = (
<InfoTab
team={team}
hasChanges={hasChanges}
setHasChanges={setHasChanges}
hasChangeTabError={hasChangeTabError}
setHasChangeTabError={setHasChangeTabError}
setJustSaved={setJustSaved}
closeModal={closeModal}
collapseModal={collapseModal}
areThereUnsavedChanges={areThereUnsavedChanges}
setAreThereUnsavedChanges={setAreThereUnsavedChanges}
showTabSwitchError={showTabSwitchError}
setShowTabSwitchError={setShowTabSwitchError}
/>
);
break;
@ -55,13 +46,10 @@ const TeamSettings = ({
result = (
<AccessTab
team={team}
hasChanges={hasChanges}
setHasChanges={setHasChanges}
hasChangeTabError={hasChangeTabError}
setHasChangeTabError={setHasChangeTabError}
setJustSaved={setJustSaved}
closeModal={closeModal}
collapseModal={collapseModal}
areThereUnsavedChanges={areThereUnsavedChanges}
setAreThereUnsavedChanges={setAreThereUnsavedChanges}
showTabSwitchError={showTabSwitchError}
setShowTabSwitchError={setShowTabSwitchError}
/>
);
break;

View file

@ -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';

View file

@ -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;
}
}
}

View file

@ -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', () => {
<TeamSettingsModal
{...baseProps}
/>,
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(
<TeamSettingsModal
{...props}
{...baseProps}
/>,
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(
<TeamSettingsModal
{...props}
{...baseProps}
/>,
stateWithoutPermission,
);
const tabs = screen.getAllByRole('tab');
expect(tabs.length).toEqual(1);
@ -56,23 +124,10 @@ describe('components/team_settings_modal', () => {
<TeamSettingsModal
{...baseProps}
/>,
{
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', () => {
<TeamSettingsModal
{...baseProps}
/>,
{
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', () => {
<TeamSettingsModal
{...baseProps}
/>,
{
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', () => {
<TeamSettingsModal
{...baseProps}
/>,
{
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();
});
});
});

View file

@ -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<boolean>(true);
const [hasChanges, setHasChanges] = useState<boolean>(false);
const [hasChangeTabError, setHasChangeTabError] = useState<boolean>(false);
const [hasBeenWarned, setHasBeenWarned] = useState<boolean>(false);
const [justSaved, setJustSaved] = useState<boolean>(false);
const modalBodyRef = useRef<ModalBody>(null);
const [show, setShow] = useState(isOpen);
const [areThereUnsavedChanges, setAreThereUnsavedChanges] = useState(false);
const [showTabSwitchError, setShowTabSwitchError] = useState(false);
const [hasBeenWarned, setHasBeenWarned] = useState(false);
const modalBodyRef = useRef<HTMLDivElement>(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 (
<Modal
dialogClassName='a11y__modal settings-modal'
<GenericModal
id='teamSettingsModal'
ariaLabel={modalTitle}
className='TeamSettingsModal settings-modal'
show={show}
onHide={handleHide}
onExited={handleClose}
role='none'
aria-labelledby='teamSettingsModalLabel'
id='teamSettingsModal'
preventClose={areThereUnsavedChanges && !hasBeenWarned}
onExited={handleExited}
compassDesign={true}
modalHeaderText={modalTitle}
bodyPadding={false}
modalLocation={'top'}
enforceFocus={false}
>
<Modal.Header
id='teamSettingsModalLabel'
closeButton={true}
>
<Modal.Title
componentClass='h2'
className='modal-header__title'
<div className='TeamSettingsModal__bodyWrapper'>
<div
ref={modalBodyRef}
className='settings-table'
>
{formatMessage({id: 'team_settings_modal.title', defaultMessage: 'Team Settings'})}
</Modal.Title>
</Modal.Header>
<Modal.Body ref={modalBodyRef}>
<div className='settings-table'>
<div className='settings-links'>
<React.Suspense fallback={null}>
<SettingsSidebar
@ -115,18 +145,15 @@ const TeamSettingsModal = ({onExited, canInviteUsers, focusOriginElement}: Props
<div className='settings-content minimize-settings'>
<TeamSettings
activeTab={activeTab}
hasChanges={hasChanges}
setHasChanges={setHasChanges}
hasChangeTabError={hasChangeTabError}
setHasChangeTabError={setHasChangeTabError}
setJustSaved={setJustSaved}
closeModal={handleHide}
collapseModal={handleCollapse}
areThereUnsavedChanges={areThereUnsavedChanges}
setAreThereUnsavedChanges={setAreThereUnsavedChanges}
showTabSwitchError={showTabSwitchError}
setShowTabSwitchError={setShowTabSwitchError}
/>
</div>
</div>
</Modal.Body>
</Modal>
</div>
</GenericModal>
);
};