MM-41586: Team setting modal UI update (#25729)

* add new sections

* remove section related code

* add some todos

* convert team setting to functional component

* remove unused props from main_menu

* remove unused collapseModal

* create 2 sections files

* clean info section

* cleanup access tab

* further clean team info setting component

* fix input fields

* fix description input field

* reorganize files

* add team icon when there is no team image

* improve layout

* fix autofocus

* delete empty file

* add note related to learna bout teams link

* add edit icon

* add upload functionality

* finish image upload

* implement logic for handle save

* add remove icon button

* fix remove button color

* fix styling on image remove and upload

* fix image remove feature

* show remove image button dynamically

* fix height

* update haveImageChanges on handleTeamIconRemove cl

* fix spacing inside input

* fix cursor point

* access tab basis

* add some todo

* add baseline for client error

* handle desc and image client errors

* move folders

* rename section to tab

* move the name section to new file

* dedicated description component

* dedicated image section

* convert to functional component

* remove unnecessary fetchTeam

* remove havechanges  state

* remove not needed folder

* rename from section to tab

* convert access tab to FC

* fix invite section input

* finalize team invite code section

* add checkbox

* add select_text_input

* finish allowed domains

* fix save changes panel style

* convert open_invite

* add logic for show save changes panel globally

* handle server errors

* combine client errors

* fix save changes issue

* clean colors used in css

* fix style

* fix type issues

* fix another type

* fix allowed domains

* fix type error

* add save changes panel to access tab

* add success state to save changes panel

* remove unused prop

* cleanup css

* fix save changes modal position

* fix title font size

* remove not used prop

* fix mobile view width

* fix mobile view

* add group constraint text

* handle invite code error

* update snapshots

* fix input height

* fix tests

* write tests for open_invite

* write tests for team_info_tab

* write tests for team_access_tab

* Refactor setTeamIcon test in teams.test.ts

* Refactor team access and team info tabs for save changes panel

* Add useEffect hook to set inviteId in AccessTab component

* fix lint

* fix lint

* fix i18

* remove old todo

* fix text

* fix css

* fix css

* fix padding

* fix mobile view

* update snapshot

* performance improvements

* fix type

* improve translation passing to components

* fix lint

* rename saving to editing

* fix empty allowed domains

* complete renaming of saving

* seperate AllowedDomainsSelect

* seperate InviteSectionInput

* fix i18n

* capitalize translation id

* final fix for i18n

* remove empty file

* fix lint and test

* fix rgb values

* remove action related types from index file

* add last_team_icon_update to Team type

* fix unnecessary null check operator

* fix more types

* add new features for text selector

* update text for select text input

* fix style issues on save changes

* fix lint check

* add animation for save changes panel

* remove unused type

* fix test

* fix theming issues

* fix MM-T385

* fix MM-T388

* fix MM-T387 and MM-T2341

* fix MM-T391

* Fix MM-T2318, MM-T2317, MM-T2312, MM-T2322, MM-T2335

* fix top padding

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Sinan Sonmez (Chaush) 2024-02-20 23:46:35 +01:00 committed by GitHub
parent c064c3a979
commit bff19228e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 2254 additions and 2129 deletions

View File

@ -59,13 +59,16 @@ describe('Team Settings', () => {
// * Check that the 'Team Settings' modal was opened
cy.get('#teamSettingsModal').should('exist').within(() => {
cy.get('#open_inviteDesc').should('have.text', 'No');
// # Go to Access section
cy.get('#accessButton').click();
// # Click on the 'Allow only users with a specific email domain to join this team' edit button
cy.get('#allowed_domainsEdit').should('be.visible').click();
cy.get('.access-allowed-domains-section').should('exist').within(() => {
// # Click on the 'Allow only users with a specific email domain to join this team' checkbox
cy.get('.mm-modal-generic-section-item__input-checkbox').should('not.be.checked').click();
});
// * Verify that the '#allowedDomains' input field is empty
cy.get('#allowedDomains').should('be.empty');
cy.get('#allowedDomains').should('have.text', 'corp.mattermost.com, mattermost.com');
// # Close the modal
cy.get('#teamSettingsModalLabel').find('button').should('be.visible').click();

View File

@ -38,12 +38,16 @@ describe('Team Settings', () => {
// * Check that the 'Team Settings' modal was opened
cy.get('#teamSettingsModal').should('exist').within(() => {
// # Click on the 'Allow only users with a specific email domain to join this team' edit button
cy.get('#allowed_domainsEdit').should('be.visible').click();
// # Go to Access section
cy.get('#accessButton').click();
cy.get('.access-allowed-domains-section').should('exist').within(() => {
// # Click on the 'Allow only users with a specific email domain to join this team' checkbox
cy.get('.mm-modal-generic-section-item__input-checkbox').should('not.be.checked').click();
});
// # Set 'sample.mattermost.com' as the only allowed email domain and save
cy.wait(TIMEOUTS.HALF_SEC);
cy.focused().type(emailDomain);
cy.get('#allowedDomains').click().type(emailDomain).type(' ');
cy.findByText('Save').should('be.visible').click();
// # Close the modal

View File

@ -11,7 +11,6 @@
// Group: @channels @team_settings
import {getRandomId, stubClipboard} from '../../../utils';
import * as TIMEOUTS from '../../../fixtures/timeouts';
describe('Team Settings', () => {
const randomId = getRandomId();
@ -47,15 +46,22 @@ describe('Team Settings', () => {
// * Check that the 'Team Settings' modal was opened
cy.get('#teamSettingsModal').should('exist').within(() => {
// # Click on the 'Allow only users with a specific email domain to join this team' edit button
cy.get('#allowed_domainsEdit').should('be.visible').click();
// # Go to Access section
cy.get('#accessButton').click();
// # Set 'sample.mattermost.com' as the only allowed email domain, save then close
cy.wait(TIMEOUTS.HALF_SEC);
cy.focused().type(emailDomain);
cy.uiSaveAndClose();
// # Click on the 'Allow only users with a specific email domain to join this team' edit button
cy.get('.access-allowed-domains-section').should('exist').within(() => {
// # Click on the 'Allow only users with a specific email domain to join this team' checkbox
cy.get('.mm-modal-generic-section-item__input-checkbox').should('not.be.checked').click();
});
// # Set 'sample.mattermost.com' as the only allowed email domain and save
cy.get('#allowedDomains').click().type(emailDomain).type(' ');
cy.findByText('Save').should('be.visible').click();
});
cy.uiClose();
// # Open team menu and click 'Invite People'
cy.uiOpenTeamMenu('Invite People');
@ -92,26 +98,26 @@ describe('Team Settings', () => {
// * Check that the 'Team Settings' modal was opened
cy.get('#teamSettingsModal').should('exist').within(() => {
// # Click on the 'Allow any user with an account on this server to join this team' edit button
cy.get('#open_inviteEdit').should('be.visible').click();
// # Go to Access section
cy.get('#accessButton').click();
// # Enable any user with an account on the server to join the team
cy.get('#teamOpenInvite').should('be.visible').check();
// # Save and verify it took effect
cy.uiSave();
cy.get('#open_inviteDesc').should('be.visible').and('have.text', 'Yes');
cy.get('.access-invite-domains-section').should('exist').within(() => {
// # Enable any user with an account on the server to join the team
cy.get('.mm-modal-generic-section-item__input-checkbox').should('not.be.checked').click();
});
// # Click on the 'Allow only users with a specific email domain to join this team' edit button
cy.get('#allowed_domainsEdit').should('be.visible').click();
cy.get('.access-allowed-domains-section').should('exist').within(() => {
// # Click on the 'Allow only users with a specific email domain to join this team' checkbox
cy.get('.mm-modal-generic-section-item__input-checkbox').should('not.be.checked').click();
});
// # Set 'sample.mattermost.com' as the only allowed email domain and save
cy.wait(TIMEOUTS.HALF_SEC);
cy.findByRole('textbox', {name: 'Allowed Domains'}).should('be.visible').and('be.focused').type(emailDomain);
cy.get('#allowedDomains').click().type(emailDomain).type(' ');
cy.findByText('Save').should('be.visible').click();
// # Save and verify it took effect
cy.uiSave();
cy.get('#allowed_domainsDesc').should('be.visible').and('have.text', emailDomain);
// # Close the modal
cy.uiClose();

View File

@ -28,56 +28,34 @@ describe('Teams Settings', () => {
// # Open team settings dialog
openTeamSettingsDialog();
// * Verify the settings picture button is visible to click
cy.findByTestId('inputSettingPictureButton').should('be.visible').click();
// * Before uploading the picture the save button must be disabled
cy.uiSaveButton().should('be.disabled');
// # Upload a file on center view
cy.findByTestId('uploadPicture').attachFile('mattermost-icon.png');
// * Save then close
cy.uiSaveAndClose();
// * Save
cy.uiSave();
// * Verify team icon
cy.get(`#${testTeam.name}TeamButton`).within(() => {
cy.findByTestId('teamIconImage').should('be.visible');
cy.findByTestId('teamIconInitial').should('not.exist');
});
// # Open the team settings dialog
openTeamSettingsDialog();
// # Click on 'X' icon to remove the image
cy.findByTestId('removeSettingPicture').should('be.visible').click();
// # Click on the cancel button
cy.findByTestId('cancelSettingPicture').should('be.visible').click();
cy.get('#teamIconImage').should('be.visible');
cy.get('#teamIconInitial').should('not.exist');
// # Close the team settings dialog
cy.uiClose();
// * Verify the team icon image is visible and initial team holder is not visible
cy.get(`#${testTeam.name}TeamButton`).within(() => {
cy.findByTestId('teamIconImage').should('be.visible');
cy.findByTestId('teamIconInitial').should('not.exist');
});
// # Open team settings dialog
// # Open the team settings dialog
openTeamSettingsDialog();
// # Click on 'X' icon to remove the image
cy.findByTestId('removeSettingPicture').should('be.visible').click();
// # Click on 'Remove Image' button to remove the image
cy.findByTestId('removeImageButton').should('be.visible').click();
// # Save and close the modal
cy.uiSaveAndClose();
// # Close the modal
cy.uiClose();
// # Open the team settings dialog
openTeamSettingsDialog();
// * After removing the team icon initial team holder is visible but not team icon holder
cy.get(`#${testTeam.name}TeamButton`).within(() => {
cy.findByTestId('teamIconImage').should('not.exist');
cy.findByTestId('teamIconInitial').should('be.visible');
});
cy.get('#teamIconImage').should('not.exist');
cy.get('#teamIconInitial').should('be.visible');
});
});
@ -88,9 +66,11 @@ function openTeamSettingsDialog() {
// * Verify the team settings dialog is open
cy.get('#teamSettingsModalLabel').should('be.visible').and('contain', 'Team Settings');
// * Verify the edit icon is visible
cy.get('#team_iconEdit').should('be.visible');
cy.get('.team-picture-section').within(() => {
// * Verify the edit icon is visible
cy.get('.icon-pencil-outline').should('be.visible');
// # Click on edit button
cy.get('#team_iconEdit').click();
// # Click on edit button
cy.get('.icon-pencil-outline').click();
});
}

View File

@ -186,6 +186,9 @@ describe('Teams Suite', () => {
// # Open team menu and click "Team Settings"
cy.uiOpenTeamMenu('Team Settings');
// # Go to Access section
cy.get('#accessButton').click();
// # Open edit settings for invite code
cy.findByText('Invite Code').should('be.visible').click();
@ -213,11 +216,8 @@ describe('Teams Suite', () => {
// # Change team name in the input
cy.get('#teamName').should('be.visible').clear().type(teamName);
// Save new team name
cy.findByText(/save/i).click();
// # Close the team settings
cy.get('body').typeWithForce('{esc}');
// Save new team name annd close<
cy.uiSaveAndClose();
// Team display name shows as "Testing Team" at top of team menu
cy.uiGetLHSHeader().findByText(teamName);
@ -238,9 +238,6 @@ describe('Teams Suite', () => {
// # Open team menu and click "Team Settings"
cy.uiOpenTeamMenu('Team Settings');
// # Click on the team description menu item
cy.findByText('Team Description').should('be.visible').click();
// # Change team description in the input
cy.get('#teamDescription').should('be.visible').clear().type(teamDescription);
cy.get('#teamDescription').should('have.value', teamDescription);
@ -252,7 +249,7 @@ describe('Teams Suite', () => {
cy.uiOpenTeamMenu('Team Settings');
// * Verify team description is updated
cy.get('#descriptionDesc').should('have.text', teamDescription);
cy.get('#teamDescription').should('have.text', teamDescription);
});
it('MM-T2318 Allow anyone to join this team', () => {
@ -262,17 +259,16 @@ describe('Teams Suite', () => {
// # Open team menu and click "Team Settings"
cy.uiOpenTeamMenu('Team Settings');
// # Click on the team description menu item
cy.findByText('Allow any user with an account on this server to join this team').should('be.visible').click();
// # Go to Access section
cy.get('#accessButton').click();
// # Change team description in the input
cy.get('#teamOpenInvite').click();
cy.get('.access-invite-domains-section').should('exist').within(() => {
// # Click on the 'Allow any user with an account on this server to join this team' checkbox
cy.get('.mm-modal-generic-section-item__input-checkbox').should('not.be.checked').click();
});
// Save new team description
cy.findByText(/save/i).click();
// # Close the team settings
cy.get('body').typeWithForce('{esc}');
// # Save and close
cy.uiSaveAndClose();
// # Login as new user
cy.apiLogin(newUser);
@ -302,14 +298,16 @@ describe('Teams Suite', () => {
// # Open team menu and click "Team Settings"
cy.uiOpenTeamMenu('Team Settings');
// # Click on the team description menu item
cy.findByText('Allow any user with an account on this server to join this team').should('be.visible').click();
// # Go to Access section
cy.get('#accessButton').click();
// # Change team description in the input
cy.get('#teamOpenInviteNo').click();
cy.get('.access-invite-domains-section').should('exist').within(() => {
// # Click on the 'Allow any user with an account on this server to join this team' checkbox
cy.get('.mm-modal-generic-section-item__input-checkbox').should('not.be.checked');
});
// Save new team description and close team settings
cy.uiSaveAndClose();
// # Save and close
cy.uiClose();
// # Login as new user
cy.apiLogin(testUser);

View File

@ -96,7 +96,7 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
}
}, [props.onURLChange, displayName.current, url, displayNameModified]);
const handleOnURLChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const handleOnURLChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.preventDefault();
const {target: {value: url}} = e;

View File

@ -76,7 +76,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<h4
class="mm-modal-generic-section__title"
@ -92,6 +92,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -112,6 +113,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
Turns off notifications for this channel. Youll still see badges if youre mentioned.
</p>
@ -121,6 +123,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -141,6 +144,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
When enabled, @channel, @here and @all will not trigger mentions or mention notifications in this channel
</p>
@ -154,7 +158,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<div
class="mm-modal-generic-section__row"
@ -179,11 +183,13 @@ Object {
>
<h4
class="mm-modal-generic-section-item__title"
data-testid="mm-modal-generic-section-item__title"
>
Notify me about…
</h4>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-radio"
@ -238,7 +244,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<div
class="mm-modal-generic-section__row"
@ -280,6 +286,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -304,11 +311,13 @@ Object {
>
<h4
class="mm-modal-generic-section-item__title"
data-testid="mm-modal-generic-section-item__title"
>
Notify me about…
</h4>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-radio"
@ -511,7 +520,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<h4
class="mm-modal-generic-section__title"
@ -527,6 +536,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -547,6 +557,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
Turns off notifications for this channel. Youll still see badges if youre mentioned.
</p>
@ -556,6 +567,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -576,6 +588,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
When enabled, @channel, @here and @all will not trigger mentions or mention notifications in this channel
</p>
@ -589,7 +602,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<div
class="mm-modal-generic-section__row"
@ -631,11 +644,13 @@ Object {
>
<h4
class="mm-modal-generic-section-item__title"
data-testid="mm-modal-generic-section-item__title"
>
Notify me about…
</h4>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-radio"
@ -690,7 +705,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<div
class="mm-modal-generic-section__row"
@ -732,6 +747,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -756,11 +772,13 @@ Object {
>
<h4
class="mm-modal-generic-section-item__title"
data-testid="mm-modal-generic-section-item__title"
>
Notify me about…
</h4>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-radio"
@ -963,7 +981,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<h4
class="mm-modal-generic-section__title"
@ -979,6 +997,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -999,6 +1018,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
Turns off notifications for this channel. Youll still see badges if youre mentioned.
</p>
@ -1008,6 +1028,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -1028,6 +1049,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
When enabled, @channel, @here and @all will not trigger mentions or mention notifications in this channel
</p>
@ -1041,7 +1063,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<div
class="mm-modal-generic-section__row"
@ -1066,11 +1088,13 @@ Object {
>
<h4
class="mm-modal-generic-section-item__title"
data-testid="mm-modal-generic-section-item__title"
>
Notify me about…
</h4>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-radio"
@ -1125,7 +1149,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<div
class="mm-modal-generic-section__row"
@ -1167,6 +1191,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -1191,11 +1216,13 @@ Object {
>
<h4
class="mm-modal-generic-section-item__title"
data-testid="mm-modal-generic-section-item__title"
>
Notify me about…
</h4>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-radio"
@ -1398,7 +1425,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<h4
class="mm-modal-generic-section__title"
@ -1414,6 +1441,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -1434,6 +1462,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
Turns off notifications for this channel. Youll still see badges if youre mentioned.
</p>
@ -1443,6 +1472,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -1463,6 +1493,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
When enabled, @channel, @here and @all will not trigger mentions or mention notifications in this channel
</p>
@ -1659,7 +1690,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<h4
class="mm-modal-generic-section__title"
@ -1675,6 +1706,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -1695,6 +1727,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
Turns off notifications for this channel. Youll still see badges if youre mentioned.
</p>
@ -1704,6 +1737,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -1724,6 +1758,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
When enabled, @channel, @here and @all will not trigger mentions or mention notifications in this channel
</p>
@ -1737,7 +1772,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<div
class="mm-modal-generic-section__row"
@ -1762,11 +1797,13 @@ Object {
>
<h4
class="mm-modal-generic-section-item__title"
data-testid="mm-modal-generic-section-item__title"
>
Notify me about…
</h4>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-radio"
@ -1821,7 +1858,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<div
class="mm-modal-generic-section__row"
@ -1863,6 +1900,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -2040,7 +2078,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<h4
class="mm-modal-generic-section__title"
@ -2056,6 +2094,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -2076,6 +2115,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
Turns off notifications for this channel. Youll still see badges if youre mentioned.
</p>
@ -2085,6 +2125,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -2105,6 +2146,7 @@ Object {
</div>
<p
class="mm-modal-generic-section-item__description"
data-testid="mm-modal-generic-section-item__description"
>
When enabled, @channel, @here and @all will not trigger mentions or mention notifications in this channel
</p>
@ -2118,7 +2160,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<div
class="mm-modal-generic-section__row"
@ -2143,11 +2185,13 @@ Object {
>
<h4
class="mm-modal-generic-section-item__title"
data-testid="mm-modal-generic-section-item__title"
>
Notify me about…
</h4>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-radio"
@ -2202,7 +2246,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<div
class="mm-modal-generic-section__row"
@ -2244,6 +2288,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"
@ -2268,11 +2313,13 @@ Object {
>
<h4
class="mm-modal-generic-section-item__title"
data-testid="mm-modal-generic-section-item__title"
>
Notify me about…
</h4>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-radio"
@ -2326,7 +2373,7 @@ Object {
class="mm-modal-generic-section"
>
<div
class="mm-modal-generic-section__info-ctr"
class="mm-modal-generic-section__title-description-ctr"
>
<h4
class="mm-modal-generic-section__title"
@ -2347,6 +2394,7 @@ Object {
>
<div
class="mm-modal-generic-section-item__content"
data-testid="mm-modal-generic-section-item__content"
>
<fieldset
class="mm-modal-generic-section-item__fieldset-checkbox-ctr"

View File

@ -0,0 +1,9 @@
.select-text-description {
margin-top: 8px;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-family: Open Sans;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}

View File

@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import type {CSSProperties, KeyboardEventHandler} from 'react';
import CreatableSelect from 'react-select/creatable';
import './select_text_input.scss';
const components = {
DropdownIndicator: null,
};
export interface SelectTextInputOption {
label: string;
value: string;
}
type Props = {
placeholder: string;
value: string[];
handleNewSelection: (selection: string) => void;
onChange: (option?: SelectTextInputOption[] | null) => void;
id?: string;
isClearable?: boolean;
description?: string;
}
const styles = {
control: (baseStyles: CSSProperties) => ({
...baseStyles,
background: 'var(--center-channel-color-rgb)',
}),
input: (baseStyles: CSSProperties) => ({
...baseStyles,
color: 'rgba(var(--center-channel-color-rgb), 0.64)',
}),
multiValue: (baseStyles: CSSProperties) => ({
...baseStyles,
borderRadius: '10px',
background: 'rgba(var(--center-channel-color-rgb), 0.08)',
display: 'flex',
alignItems: 'center',
}),
multiValueLabel: (baseStyles: CSSProperties) => ({
...baseStyles,
padding: '4px 6px 4px 10px',
color: 'var(--center-channel-color)',
fontFamily: 'Open Sans',
fontSize: '10px',
fontWeight: 600,
lineHeight: '12px',
letterSpacing: '0.2px',
}),
multiValueRemove: (baseStyles: CSSProperties) => ({
...baseStyles,
borderRadius: '50%',
background: 'rgba(var(--center-channel-color-rgb), 0.32)',
fontFamily: 'compass-icons',
fontSize: '12px',
fontWeight: 400,
color: 'white',
width: '10px',
height: '10px',
padding: 0,
marginRight: '4px',
':hover': {
background: 'rgba(var(--center-channel-color-rgb), 0.32)',
color: 'white',
},
}),
};
const SelectTextInput = ({placeholder, value, handleNewSelection, onChange, id, isClearable, description}: Props) => {
const [inputValue, setInputValue] = React.useState('');
const handleTextEnter = useCallback(() => {
// do not add the value if already exists
if (value?.includes(inputValue.trim()) || inputValue.length === 0) {
return;
}
handleNewSelection(inputValue);
setInputValue('');
}, [handleNewSelection, inputValue, value]);
const handleKeyDown: KeyboardEventHandler = useCallback((event) => {
if (!inputValue) {
return;
}
switch (event.key) {
case ' ':
case ',':
case 'Enter':
handleTextEnter();
event.preventDefault();
}
}, [inputValue, handleTextEnter]);
const selectValues = useMemo(() => {
return value.map((singleValue) => ({label: singleValue, value: singleValue}));
}, [value]);
return (
<>
<CreatableSelect
id={id}
className='select-text-input'
styles={styles}
components={components}
isClearable={isClearable}
onChange={useCallback((value) => onChange(value as SelectTextInputOption[]), [onChange])}
inputValue={inputValue}
isMulti={true}
menuIsOpen={false}
onInputChange={setInputValue}
onKeyDown={handleKeyDown}
placeholder={placeholder}
value={selectValues}
onBlur={handleTextEnter}
/>
{description ? <p className='select-text-description'>{description}</p> : undefined}
</>
);
};
export default SelectTextInput;

View File

@ -16,9 +16,8 @@ import {haveICurrentTeamPermission, haveISystemPermission} from 'mattermost-redu
import {
getJoinableTeamIds,
getCurrentTeam,
getCurrentRelativeTeamUrl,
} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUser, isFirstAdmin} from 'mattermost-redux/selectors/entities/users';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {openModal} from 'actions/views/modals';
import {showMentions, showFlaggedPosts, closeRightHandSide, closeMenu as closeRhsMenu} from 'actions/views/rhs';
@ -83,10 +82,8 @@ function mapStateToProps(state: GlobalState) {
isMentionSearch: rhsState === RHSStates.MENTION,
teamIsGroupConstrained: Boolean(currentTeam.group_constrained),
isLicensedForLDAPGroups: state.entities.general.license.LDAPGroups === 'true',
teamUrl: getCurrentRelativeTeamUrl(state),
guestAccessEnabled: config.EnableGuestAccounts === 'true',
canInviteTeamMember,
isFirstAdmin: isFirstAdmin(state),
isCloud,
isStarterFree,
isFreeTrial,

View File

@ -52,12 +52,7 @@ describe('components/Menu', () => {
moreTeamsToJoin: false,
pluginMenuItems: [],
isMentionSearch: false,
isFirstAdmin: false,
intl: createIntl({locale: 'en', defaultLocale: 'en', timeZone: 'Etc/UTC', textComponent: 'span'}),
teamUrl: '/team',
location: {
pathname: '/team',
},
guestAccessEnabled: true,
canInviteTeamMember: true,
actions: {

View File

@ -61,15 +61,10 @@ export type Props = {
teamIsGroupConstrained: boolean;
isLicensedForLDAPGroups?: boolean;
intl: IntlShape;
teamUrl: string;
isFirstAdmin: boolean;
isCloud: boolean;
isStarterFree: boolean;
isFreeTrial: boolean;
usageDeltaTeams: number;
location: {
pathname: string;
};
guestAccessEnabled: boolean;
canInviteTeamMember: boolean;
actions: {

View File

@ -35,7 +35,7 @@ const AddressForm = (props: AddressFormProps) => {
const handleInputChange = (key: keyof Address) => (
event:
| React.ChangeEvent<HTMLInputElement>
| React.ChangeEvent<HTMLSelectElement>,
| React.ChangeEvent<HTMLTextAreaElement>,
) => {
const target = event.target;
const value = target.value;

View File

@ -65,7 +65,7 @@ const PaymentForm: React.FC<Props> = (props: Props) => {
company_name: props.customer?.name || '',
});
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement> | React.ChangeEvent<HTMLSelectElement>) => {
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement> | React.ChangeEvent<HTMLTextAreaElement>) => {
const target = event.target;
const name = target.name;
const value = target.value;

View File

@ -679,7 +679,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
);
};
const handleOnBlur = (e: FocusEvent<HTMLInputElement>, inputId: string) => {
const handleOnBlur = (e: FocusEvent<HTMLInputElement | HTMLTextAreaElement>, inputId: string) => {
const text = e.target.value;
if (!text) {
return;

View File

@ -1,129 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/TeamSettings/OpenInvite should match snapshot on active with groupConstrained 1`] = `
<SettingItemMax
containerStyle=""
infoPosition="bottom"
inputs={
Array [
<div>
<div>
<Memo(MemoizedFormattedMessage)
defaultMessage="No, members of this team are added and removed by linked groups. <link>Learn More</link>"
id="team_settings.openInviteDescription.groupConstrained"
values={
Object {
"link": [Function],
}
}
/>
</div>
</div>,
]
}
saving={false}
section=""
serverError=""
submit={[Function]}
title="Allow any user with an account on this server to join this team"
updateSection={[Function]}
/>
`;
exports[`components/TeamSettings/OpenInvite should match snapshot on active without groupConstrained 1`] = `
<SettingItemMax
containerStyle=""
infoPosition="bottom"
inputs={
Array [
<fieldset>
<legend
className="form-legend hidden-label"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Invite Code"
id="team_settings.openInviteDescription.ariaLabel"
/>
</legend>
<div
className="radio"
>
<label>
<input
defaultChecked={false}
id="teamOpenInvite"
name="userOpenInviteOptions"
onChange={[Function]}
type="radio"
/>
<Memo(MemoizedFormattedMessage)
defaultMessage="Yes"
id="general_tab.yes"
/>
</label>
<br />
</div>
<div
className="radio"
>
<label>
<input
defaultChecked={true}
id="teamOpenInviteNo"
name="userOpenInviteOptions"
onChange={[Function]}
type="radio"
/>
<Memo(MemoizedFormattedMessage)
defaultMessage="No"
id="general_tab.no"
/>
</label>
<br />
</div>
<div
className="mt-5"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="When allowed, a link to this team will be included on the landing page allowing anyone with an account to join this team. Changing from \\"Yes\\" to \\"No\\" will regenerate the invitation code, create a new invitation link and invalidate the previous link."
id="general_tab.openInviteDesc"
/>
</div>
</fieldset>,
]
}
saving={false}
section=""
serverError=""
submit={[Function]}
title="Allow any user with an account on this server to join this team"
updateSection={[Function]}
/>
`;
exports[`components/TeamSettings/OpenInvite should match snapshot on non active allowing open invite 1`] = `
<SettingItemMin
describe="Yes"
section="open_invite"
title="Allow any user with an account on this server to join this team"
updateSection={[Function]}
/>
`;
exports[`components/TeamSettings/OpenInvite should match snapshot on non active with groupConstrained 1`] = `
<SettingItemMin
describe="No, members of this team are added and removed by linked groups."
section="open_invite"
title="Allow any user with an account on this server to join this team"
updateSection={[Function]}
/>
`;
exports[`components/TeamSettings/OpenInvite should match snapshot on non active without groupConstrained 1`] = `
<SettingItemMin
describe="No"
section="open_invite"
title="Allow any user with an account on this server to join this team"
updateSection={[Function]}
/>
`;

View File

@ -1,361 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/TeamSettings hide invite code if no permissions for team inviting 1`] = `
<div>
<div
className="modal-header"
>
<button
aria-label="Close"
className="close"
data-dismiss="modal"
id="closeButton"
onClick={[MockFunction]}
type="button"
>
<span
aria-hidden="true"
>
×
</span>
</button>
<h4
className="modal-title"
>
<div
className="modal-back"
>
<span
onClick={[MockFunction]}
>
<BackIcon />
</span>
</div>
<MemoizedFormattedMessage
defaultMessage="General Settings"
id="general_tab.title"
/>
</h4>
</div>
<div
className="user-settings"
>
<div
className="GeneralTab__header"
>
<h3>
<MemoizedFormattedMessage
defaultMessage="General Settings"
id="general_tab.title"
/>
</h3>
<LearnAboutTeamsLink />
</div>
<div
className="divider-dark first"
/>
<SettingItemMin
describe="name"
section="name"
title="Team Name"
updateSection={[Function]}
/>
<div
className="divider-light"
/>
<SettingItemMin
describe=""
section="description"
title="Team Description"
updateSection={[Function]}
/>
<div
className="divider-light"
/>
<SettingPicture
clientError=""
file={null}
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Upload a team icon in BMP, JPG or PNG format.\\\\nSquare images with a solid background color are recommended."
id="setting_picture.help.team"
/>
}
imageContext="team"
loadingPicture={false}
onFileChange={[Function]}
onRemove={[Function]}
onSubmit={[Function]}
serverError=""
src={null}
submitActive={false}
title="Team Icon"
updateSection={[Function]}
/>
<div
className="divider-light"
/>
<SettingItemMin
describe=""
section="allowed_domains"
title="allowedDomains"
updateSection={[Function]}
/>
<div
className="divider-light"
/>
<OpenInvite
allowOpenInvite={false}
isActive={false}
isGroupConstrained={false}
onToggle={[Function]}
patchTeam={[MockFunction]}
teamId="team_id"
/>
<div
className="divider-light"
/>
<SettingItemMin
describe="Click 'Edit' to regenerate Invite Code."
section="invite_id"
title="Invite Code"
updateSection={[Function]}
/>
<div
className="divider-dark"
/>
</div>
</div>
`;
exports[`components/TeamSettings hide invite code if no permissions for team inviting 2`] = `
<div>
<div
className="modal-header"
>
<button
aria-label="Close"
className="close"
data-dismiss="modal"
id="closeButton"
onClick={[MockFunction]}
type="button"
>
<span
aria-hidden="true"
>
×
</span>
</button>
<h4
className="modal-title"
>
<div
className="modal-back"
>
<span
onClick={[MockFunction]}
>
<BackIcon />
</span>
</div>
<MemoizedFormattedMessage
defaultMessage="General Settings"
id="general_tab.title"
/>
</h4>
</div>
<div
className="user-settings"
>
<div
className="GeneralTab__header"
>
<h3>
<MemoizedFormattedMessage
defaultMessage="General Settings"
id="general_tab.title"
/>
</h3>
<LearnAboutTeamsLink />
</div>
<div
className="divider-dark first"
/>
<SettingItemMin
describe="name"
section="name"
title="Team Name"
updateSection={[Function]}
/>
<div
className="divider-light"
/>
<SettingItemMin
describe=""
section="description"
title="Team Description"
updateSection={[Function]}
/>
<div
className="divider-light"
/>
<SettingPicture
clientError=""
file={null}
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Upload a team icon in BMP, JPG or PNG format.\\\\nSquare images with a solid background color are recommended."
id="setting_picture.help.team"
/>
}
imageContext="team"
loadingPicture={false}
onFileChange={[Function]}
onRemove={[Function]}
onSubmit={[Function]}
serverError=""
src={null}
submitActive={false}
title="Team Icon"
updateSection={[Function]}
/>
<div
className="divider-light"
/>
<SettingItemMin
describe=""
section="allowed_domains"
title="allowedDomains"
updateSection={[Function]}
/>
<div
className="divider-light"
/>
<OpenInvite
allowOpenInvite={false}
isActive={false}
isGroupConstrained={false}
onToggle={[Function]}
patchTeam={[MockFunction]}
teamId="team_id"
/>
<div
className="divider-light"
/>
<div
className="divider-dark"
/>
</div>
</div>
`;
exports[`components/TeamSettings should match snapshot when team is group constrained 1`] = `
<div>
<div
className="modal-header"
>
<button
aria-label="Close"
className="close"
data-dismiss="modal"
id="closeButton"
onClick={[MockFunction]}
type="button"
>
<span
aria-hidden="true"
>
×
</span>
</button>
<h4
className="modal-title"
>
<div
className="modal-back"
>
<span
onClick={[MockFunction]}
>
<BackIcon />
</span>
</div>
<MemoizedFormattedMessage
defaultMessage="General Settings"
id="general_tab.title"
/>
</h4>
</div>
<div
className="user-settings"
>
<div
className="GeneralTab__header"
>
<h3>
<MemoizedFormattedMessage
defaultMessage="General Settings"
id="general_tab.title"
/>
</h3>
<LearnAboutTeamsLink />
</div>
<div
className="divider-dark first"
/>
<SettingItemMin
describe="TestTeam"
section="name"
title="Team Name"
updateSection={[Function]}
/>
<div
className="divider-light"
/>
<SettingItemMin
describe="The Test Team"
section="description"
title="Team Description"
updateSection={[Function]}
/>
<div
className="divider-light"
/>
<SettingPicture
clientError=""
file={null}
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Upload a team icon in BMP, JPG or PNG format.\\\\nSquare images with a solid background color are recommended."
id="setting_picture.help.team"
/>
}
imageContext="team"
loadingPicture={false}
onFileChange={[Function]}
onRemove={[Function]}
onSubmit={[Function]}
serverError=""
src={null}
submitActive={false}
title="Team Icon"
updateSection={[Function]}
/>
<div
className="divider-light"
/>
<OpenInvite
allowOpenInvite={false}
isActive={false}
isGroupConstrained={true}
onToggle={[Function]}
patchTeam={[MockFunction]}
teamId="team_id"
/>
<div
className="divider-dark"
/>
</div>
</div>
`;

View File

@ -1,60 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import OpenInvite from './open_invite';
describe('components/TeamSettings/OpenInvite', () => {
const patchTeam = jest.fn().mockReturnValue({data: true});
const onToggle = jest.fn().mockReturnValue({data: true});
const defaultProps = {
teamId: 'team_id',
isActive: false,
isGroupConstrained: false,
allowOpenInvite: false,
patchTeam,
onToggle,
};
test('should match snapshot on non active without groupConstrained', () => {
const props = {...defaultProps};
const wrapper = shallow(<OpenInvite {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot on non active allowing open invite', () => {
const props = {...defaultProps, allowOpenInvite: true};
const wrapper = shallow(<OpenInvite {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot on non active with groupConstrained', () => {
const props = {...defaultProps, isGroupConstrained: true};
const wrapper = shallow(<OpenInvite {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot on active without groupConstrained', () => {
const props = {...defaultProps, isActive: true};
const wrapper = shallow(<OpenInvite {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot on active with groupConstrained', () => {
const props = {...defaultProps, isActive: true, isGroupConstrained: true};
const wrapper = shallow(<OpenInvite {...props}/>);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -1,160 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {Team} from '@mattermost/types/teams';
import type {ActionResult} from 'mattermost-redux/types/actions';
import ExternalLink from 'components/external_link';
import SettingItemMax from 'components/setting_item_max';
import SettingItemMin from 'components/setting_item_min';
type Props = {
teamId: string;
isActive: boolean;
isGroupConstrained?: boolean;
allowOpenInvite?: boolean;
onToggle: (active: boolean) => void;
patchTeam: (patch: Partial<Team> & {id: string}) => Promise<ActionResult>;
};
const OpenInvite = (props: Props) => {
const {teamId, isActive, isGroupConstrained, onToggle, patchTeam} = props;
const intl = useIntl();
const [serverError, setServerError] = useState('');
const [allowOpenInvite, setAllowOpenInvite] = useState(props.allowOpenInvite);
const submit = useCallback(() => {
setServerError('');
const data = {
id: teamId,
allow_open_invite: allowOpenInvite,
};
patchTeam(data).then(({error}) => {
if (error) {
setServerError(error.message);
} else {
onToggle(false);
}
});
}, [onToggle, patchTeam, teamId, allowOpenInvite]);
const handleToggle = useCallback(() => {
if (isActive) {
onToggle(false);
} else {
onToggle(true);
setAllowOpenInvite(props.allowOpenInvite);
}
}, [isActive, onToggle]);
if (!isActive) {
let describe = '';
if (props.allowOpenInvite) {
describe = intl.formatMessage({id: 'general_tab.yes', defaultMessage: 'Yes'});
} else if (isGroupConstrained) {
describe = intl.formatMessage({id: 'team_settings.openInviteSetting.groupConstrained', defaultMessage: 'No, members of this team are added and removed by linked groups.'});
} else {
describe = intl.formatMessage({id: 'general_tab.no', defaultMessage: 'No'});
}
return (
<SettingItemMin
title={intl.formatMessage({id: 'general_tab.openInviteTitle', defaultMessage: 'Allow any user with an account on this server to join this team'})}
describe={describe}
updateSection={handleToggle}
section={'open_invite'}
/>
);
}
let inputs;
if (isGroupConstrained) {
inputs = [
<div key='userOpenInviteOptions'>
<div>
<FormattedMessage
id='team_settings.openInviteDescription.groupConstrained'
defaultMessage='No, members of this team are added and removed by linked groups. <link>Learn More</link>'
values={{
link: (msg: React.ReactNode) => (
<ExternalLink
href='https://mattermost.com/pl/default-ldap-group-constrained-team-channel.html'
location='open_invite'
>
{msg}
</ExternalLink>
),
}}
/>
</div>
</div>,
];
} else {
inputs = [
<fieldset key='userOpenInviteOptions'>
<legend className='form-legend hidden-label'>
<FormattedMessage
id='team_settings.openInviteDescription.ariaLabel'
defaultMessage='Invite Code'
/>
</legend>
<div className='radio'>
<label>
<input
id='teamOpenInvite'
name='userOpenInviteOptions'
type='radio'
defaultChecked={allowOpenInvite}
onChange={() => setAllowOpenInvite(true)}
/>
<FormattedMessage
id='general_tab.yes'
defaultMessage='Yes'
/>
</label>
<br/>
</div>
<div className='radio'>
<label>
<input
id='teamOpenInviteNo'
name='userOpenInviteOptions'
type='radio'
defaultChecked={!allowOpenInvite}
onChange={() => setAllowOpenInvite(false)}
/>
<FormattedMessage
id='general_tab.no'
defaultMessage='No'
/>
</label>
<br/>
</div>
<div className='mt-5'>
<FormattedMessage
id='general_tab.openInviteDesc'
defaultMessage='When allowed, a link to this team will be included on the landing page allowing anyone with an account to join this team. Changing from "Yes" to "No" will regenerate the invitation code, create a new invitation link and invalidate the previous link.'
/>
</div>
</fieldset>,
];
}
return (
<SettingItemMax
title={intl.formatMessage({id: 'general_tab.openInviteTitle', defaultMessage: 'Allow any user with an account on this server to join this team'})}
inputs={inputs}
submit={submit}
serverError={serverError}
updateSection={handleToggle}
/>
);
};
export default OpenInvite;

View File

@ -1,17 +0,0 @@
.GeneralTab__header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 20px 0;
>h3 {
margin: 0;
span {
display: inline-flex;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
}
}

View File

@ -1,213 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import type {ChangeEvent, ComponentProps} from 'react';
import {GeneralTab} from 'components/team_general_tab/team_general_tab';
import {type MockIntl} from 'tests/helpers/intl-test-helper';
import {TestHelper} from 'utils/test_helper';
describe('components/TeamSettings', () => {
const getTeam = jest.fn().mockResolvedValue({data: true});
const patchTeam = jest.fn().mockReturnValue({data: true});
const regenerateTeamInviteId = jest.fn().mockReturnValue({data: true});
const removeTeamIcon = jest.fn().mockReturnValue({data: true});
const setTeamIcon = jest.fn().mockReturnValue({data: true});
const baseActions = {
getTeam,
patchTeam,
regenerateTeamInviteId,
removeTeamIcon,
setTeamIcon,
};
const defaultProps: ComponentProps<typeof GeneralTab> = {
team: TestHelper.getTeamMock({id: 'team_id'}),
maxFileSize: 50,
activeSection: 'team_icon',
intl: {
formatMessage: jest.fn(),
} as MockIntl,
updateSection: jest.fn(),
closeModal: jest.fn(),
collapseModal: jest.fn(),
actions: baseActions,
canInviteTeamMembers: true,
isMobileView: false,
};
test('should handle bad updateTeamIcon function call', () => {
const wrapper = shallow<GeneralTab>(<GeneralTab {...defaultProps}/>);
wrapper.instance().updateTeamIcon(null as unknown as ChangeEvent<HTMLInputElement>);
expect(wrapper.state('clientError')).toEqual('An error occurred while selecting the image.');
});
test('should handle invalid file selection', () => {
const wrapper = shallow<GeneralTab>(<GeneralTab {...defaultProps}/>);
wrapper.instance().updateTeamIcon({
target: {
files: [{
type: 'text/plain',
}],
},
} as unknown as ChangeEvent<HTMLInputElement>);
expect(wrapper.state('clientError')).toEqual('Only BMP, JPG or PNG images may be used for team icons');
});
test('should handle too large files', () => {
const wrapper = shallow<GeneralTab>(<GeneralTab {...defaultProps}/>);
wrapper.instance().updateTeamIcon({
target: {
files: [{
type: 'image/jpeg',
size: defaultProps.maxFileSize + 1,
}],
},
} as unknown as ChangeEvent<HTMLInputElement>);
expect(wrapper.state('clientError')).toEqual('Unable to upload team icon. File is too large.');
});
test('should call actions.setTeamIcon on handleTeamIconSubmit', () => {
const actions = {...baseActions};
const props = {...defaultProps, actions};
const wrapper = shallow<GeneralTab>(<GeneralTab {...props}/>);
let teamIconFile = null;
wrapper.setState({teamIconFile, submitActive: true});
wrapper.instance().handleTeamIconSubmit();
expect(actions.setTeamIcon).not.toHaveBeenCalled();
teamIconFile = {file: 'team_icon_file'} as unknown as File;
wrapper.setState({teamIconFile, submitActive: false});
wrapper.instance().handleTeamIconSubmit();
expect(actions.setTeamIcon).not.toHaveBeenCalled();
wrapper.setState({teamIconFile, submitActive: true});
wrapper.instance().handleTeamIconSubmit();
expect(actions.setTeamIcon).toHaveBeenCalledTimes(1);
expect(actions.setTeamIcon).toHaveBeenCalledWith(props.team?.id, teamIconFile);
});
test('should call actions.removeTeamIcon on handleTeamIconRemove', () => {
const actions = {...baseActions};
const props = {...defaultProps, actions};
const wrapper = shallow<GeneralTab>(<GeneralTab {...props}/>);
wrapper.instance().handleTeamIconRemove();
expect(actions.removeTeamIcon).toHaveBeenCalledTimes(1);
expect(actions.removeTeamIcon).toHaveBeenCalledWith(props.team?.id);
});
test('hide invite code if no permissions for team inviting', () => {
const props = {...defaultProps, canInviteTeamMembers: false};
const wrapper1 = shallow(<GeneralTab {...defaultProps}/>);
const wrapper2 = shallow(<GeneralTab {...props}/>);
expect(wrapper1).toMatchSnapshot();
expect(wrapper2).toMatchSnapshot();
});
test('should call actions.patchTeam on handleAllowedDomainsSubmit', () => {
const actions = {...baseActions};
const props = {...defaultProps, actions};
const wrapper = shallow<GeneralTab>(<GeneralTab {...props}/>);
wrapper.instance().handleAllowedDomainsSubmit();
expect(actions.patchTeam).toHaveBeenCalledTimes(1);
expect(actions.patchTeam).toHaveBeenCalledWith({
allowed_domains: '',
id: props.team?.id,
});
});
test('should call actions.patchTeam on handleNameSubmit', () => {
const actions = {...baseActions};
const props = {...defaultProps, actions};
if (props.team) {
props.team.display_name = 'TestTeam';
}
const wrapper = shallow<GeneralTab>(<GeneralTab {...props}/>);
wrapper.instance().handleNameSubmit();
expect(actions.patchTeam).toHaveBeenCalledTimes(1);
expect(actions.patchTeam).toHaveBeenCalledWith({
display_name: props.team?.display_name,
id: props.team?.id,
});
});
test('should call actions.patchTeam on handleInviteIdSubmit', () => {
const actions = {...baseActions};
const props = {...defaultProps, actions};
if (props.team) {
props.team.invite_id = '12345';
}
const wrapper = shallow<GeneralTab>(<GeneralTab {...props}/>);
wrapper.instance().handleInviteIdSubmit();
expect(actions.regenerateTeamInviteId).toHaveBeenCalledTimes(1);
expect(actions.regenerateTeamInviteId).toHaveBeenCalledWith(props.team?.id);
});
test('should call actions.patchTeam on handleDescriptionSubmit', () => {
const actions = {...baseActions};
const props = {...defaultProps, actions};
const wrapper = shallow<GeneralTab>(<GeneralTab {...props}/>);
const newDescription = 'The Test Team';
wrapper.setState({description: newDescription});
wrapper.instance().handleDescriptionSubmit();
if (props.team) {
props.team.description = newDescription;
}
expect(actions.patchTeam).toHaveBeenCalledTimes(1);
expect(actions.patchTeam).toHaveBeenCalledWith({
description: newDescription,
id: props.team?.id,
});
});
test('should match snapshot when team is group constrained', () => {
const props = {...defaultProps};
if (props.team) {
props.team.group_constrained = true;
}
const wrapper = shallow(<GeneralTab {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should call actions.getTeam on handleUpdateSection if invite_id is empty', () => {
const actions = {...baseActions};
const props = {...defaultProps, actions};
if (props.team) {
props.team.invite_id = '';
}
const wrapper = shallow<GeneralTab>(<GeneralTab {...props}/>);
wrapper.instance().handleUpdateSection('invite_id');
expect(actions.getTeam).toHaveBeenCalledTimes(1);
expect(actions.getTeam).toHaveBeenCalledWith(props.team?.id);
});
});

View File

@ -1,702 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ChangeEvent, MouseEvent, ReactNode} from 'react';
import {FormattedMessage, FormattedDate, injectIntl, type WrappedComponentProps} from 'react-intl';
import type {Team} from '@mattermost/types/teams';
import LearnAboutTeamsLink from 'components/main_menu/learn_about_teams_link';
import SettingItemMax from 'components/setting_item_max';
import SettingItemMin from 'components/setting_item_min';
import SettingPicture from 'components/setting_picture';
import BackIcon from 'components/widgets/icons/fa_back_icon';
import Constants from 'utils/constants';
import {imageURLForTeam, localizeMessage, moveCursorToEnd} from 'utils/utils';
import OpenInvite from './open_invite';
import type {PropsFromRedux, OwnProps} from '.';
import './team_general_tab.scss';
const ACCEPTED_TEAM_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/bmp'];
type Props = PropsFromRedux & OwnProps & WrappedComponentProps;
type State = {
name?: Team['display_name'];
invite_id?: Team['invite_id'];
description?: Team['description'];
allowed_domains?: Team['allowed_domains'];
serverError: ReactNode;
clientError: ReactNode;
teamIconFile: File | null;
loadingIcon: boolean;
submitActive: boolean;
isInitialState: boolean;
shouldFetchTeam?: boolean;
}
export class GeneralTab extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = this.setupInitialState(props);
}
updateSection = (section: string) => {
this.setState(this.setupInitialState(this.props));
this.props.updateSection(section);
};
setupInitialState(props: Props) {
const team = props.team;
return {
name: team?.display_name,
invite_id: team?.invite_id,
description: team?.description,
allowed_domains: team?.allowed_domains,
serverError: '',
clientError: '',
teamIconFile: null,
loadingIcon: false,
submitActive: false,
isInitialState: true,
};
}
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
const {team} = nextProps;
if (!prevState.isInitialState) {
return {
name: team?.display_name,
description: team?.description,
allowed_domains: team?.allowed_domains,
invite_id: team?.invite_id,
isInitialState: false,
};
}
return null;
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (!prevState.shouldFetchTeam && this.state.shouldFetchTeam) {
this.fetchTeam();
}
}
fetchTeam() {
if (this.state.serverError) {
return;
}
if (this.props.team) {
this.props.actions.getTeam(this.props.team.id).then(({error}) => {
const state = {
shouldFetchTeam: false,
serverError: '',
};
if (error) {
state.serverError = error.message;
}
this.setState(state);
});
}
}
handleAllowedDomainsSubmit = async () => {
const state = {serverError: '', clientError: ''};
const data = {
id: this.props.team?.id,
allowed_domains: this.state.allowed_domains,
};
const {error} = await this.props.actions.patchTeam(data);
if (error) {
state.serverError = error.message;
this.setState(state);
} else {
this.updateSection('');
}
};
handleNameSubmit = async () => {
const state: Pick<State, 'serverError' | 'clientError'> = {serverError: '', clientError: ''};
let valid = true;
const name = this.state.name?.trim();
if (!name) {
state.clientError = localizeMessage('general_tab.required', 'This field is required');
valid = false;
} else if (name.length < Constants.MIN_TEAMNAME_LENGTH) {
state.clientError = (
<FormattedMessage
id='general_tab.teamNameRestrictions'
defaultMessage='Team Name must be {min} or more characters up to a maximum of {max}. You can add a longer team description.'
values={{
min: Constants.MIN_TEAMNAME_LENGTH,
max: Constants.MAX_TEAMNAME_LENGTH,
}}
/>
);
valid = false;
} else {
state.clientError = '';
}
this.setState(state);
if (!valid) {
return;
}
const data = {
id: this.props.team?.id,
display_name: this.state.name,
};
const {error} = await this.props.actions.patchTeam(data);
if (error) {
state.serverError = error.message;
this.setState(state);
} else {
this.updateSection('');
}
};
handleInviteIdSubmit = async () => {
const state = {serverError: '', clientError: ''};
this.setState(state);
const {error} = await this.props.actions.regenerateTeamInviteId(this.props.team?.id || '');
if (error) {
state.serverError = error.message;
this.setState(state);
} else {
this.updateSection('');
}
};
handleClose = () => this.updateSection('');
handleDescriptionSubmit = async () => {
const state = {serverError: '', clientError: ''};
let valid = true;
const description = this.state.description?.trim();
if (description === this.props.team?.description) {
state.clientError = localizeMessage('general_tab.chooseDescription', 'Please choose a new description for your team');
valid = false;
} else {
state.clientError = '';
}
this.setState(state);
if (!valid) {
return;
}
const data = {
id: this.props.team?.id,
description: this.state.description,
};
const {error} = await this.props.actions.patchTeam(data);
if (error) {
state.serverError = error.message;
this.setState(state);
} else {
this.updateSection('');
}
};
handleTeamIconSubmit = async () => {
if (!this.state.teamIconFile || !this.state.submitActive) {
return;
}
this.setState({
loadingIcon: true,
clientError: '',
serverError: '',
});
const {error} = await this.props.actions.setTeamIcon(this.props.team?.id || '', this.state.teamIconFile);
if (error) {
this.setState({
loadingIcon: false,
serverError: error.message,
});
} else {
this.setState({
loadingIcon: false,
submitActive: false,
});
this.updateSection('');
}
};
handleTeamIconRemove = async () => {
this.setState({
loadingIcon: true,
clientError: '',
serverError: '',
});
const {error} = await this.props.actions.removeTeamIcon(this.props.team?.id || '');
if (error) {
this.setState({
loadingIcon: false,
serverError: error.message,
});
} else {
this.setState({
loadingIcon: false,
submitActive: false,
});
this.updateSection('');
}
};
componentDidMount() {
document.getElementById('team_settings')?.addEventListener('hidden.bs.modal', this.handleClose);
}
componentWillUnmount() {
document.getElementById('team_settings')?.removeEventListener('hidden.bs.modal', this.handleClose);
}
onOpenInviteToggle = (active: boolean) => this.handleUpdateSection(active ? 'open_invite' : '');
handleUpdateSection = (section: string) => {
if (section === 'invite_id' && this.props.activeSection !== section && !this.props.team?.invite_id) {
this.setState({shouldFetchTeam: true}, () => {
this.updateSection(section);
});
return;
}
this.updateSection(section);
};
updateName = (e: ChangeEvent<HTMLInputElement>) => this.setState({name: e.target.value});
updateDescription = (e: ChangeEvent<HTMLInputElement>) => this.setState({description: e.target.value});
updateTeamIcon = (e: ChangeEvent<HTMLInputElement>) => {
if (e && e.target && e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (!ACCEPTED_TEAM_IMAGE_TYPES.includes(file.type)) {
this.setState({
clientError: localizeMessage('general_tab.teamIconInvalidFileType', 'Only BMP, JPG or PNG images may be used for team icons'),
});
} else if (file.size > this.props.maxFileSize) {
this.setState({
clientError: localizeMessage('general_tab.teamIconTooLarge', 'Unable to upload team icon. File is too large.'),
});
} else {
this.setState({
teamIconFile: e.target.files[0],
clientError: '',
submitActive: true,
});
}
} else {
this.setState({
teamIconFile: null,
clientError: localizeMessage('general_tab.teamIconError', 'An error occurred while selecting the image.'),
});
}
};
updateAllowedDomains = (e: ChangeEvent<HTMLInputElement>) => this.setState({allowed_domains: e.target.value});
render() {
const team = this.props.team;
const clientError = this.state.clientError;
const serverError = this.state.serverError ?? null;
let inviteSection;
if (this.props.activeSection === 'invite_id' && this.props.canInviteTeamMembers) {
const inputs = [];
inputs.push(
<div key='teamInviteSetting'>
<div className='row'>
<label className='col-sm-5 control-label visible-xs-block'/>
<div className='col-sm-12'>
<input
id='teamInviteId'
autoFocus={true}
className='form-control'
type='text'
value={this.state.invite_id}
maxLength={32}
onFocus={moveCursorToEnd}
readOnly={true}
/>
</div>
</div>
<div className='setting-list__hint'>
<FormattedMessage
id='general_tab.codeLongDesc'
defaultMessage='The Invite Code is part of the unique team invitation link which is sent to members youre inviting to this team. Regenerating the code creates a new invitation link and invalidates the previous link.'
values={{
getTeamInviteLink: (
<strong>
<FormattedMessage
id='general_tab.getTeamInviteLink'
defaultMessage='Get Team Invite Link'
/>
</strong>
),
}}
/>
</div>
</div>,
);
inviteSection = (
<SettingItemMax
title={localizeMessage('general_tab.codeTitle', 'Invite Code')}
inputs={inputs}
submit={this.handleInviteIdSubmit}
serverError={serverError}
clientError={clientError}
updateSection={this.handleUpdateSection}
saveButtonText={localizeMessage('general_tab.regenerate', 'Regenerate')}
/>
);
} else if (this.props.canInviteTeamMembers) {
inviteSection = (
<SettingItemMin
title={localizeMessage('general_tab.codeTitle', 'Invite Code')}
describe={localizeMessage('general_tab.codeDesc', "Click 'Edit' to regenerate Invite Code.")}
updateSection={this.handleUpdateSection}
section={'invite_id'}
/>
);
}
let nameSection;
if (this.props.activeSection === 'name') {
const inputs = [];
const teamNameLabel = this.props.isMobileView ? '' : (
<FormattedMessage
id='general_tab.teamName'
defaultMessage='Team Name'
/>
);
inputs.push(
<div
key='teamNameSetting'
className='form-group'
>
<label className='col-sm-5 control-label'>{teamNameLabel}</label>
<div className='col-sm-7'>
<input
id='teamName'
autoFocus={true}
className='form-control'
type='text'
maxLength={Constants.MAX_TEAMNAME_LENGTH}
onChange={this.updateName}
value={this.state.name}
onFocus={moveCursorToEnd}
/>
</div>
</div>,
);
const nameExtraInfo = <span>{localizeMessage('general_tab.teamNameInfo', 'Set the name of the team as it appears on your sign-in screen and at the top of the left-hand sidebar.')}</span>;
nameSection = (
<SettingItemMax
title={localizeMessage('general_tab.teamName', 'Team Name')}
inputs={inputs}
submit={this.handleNameSubmit}
serverError={serverError}
clientError={clientError}
updateSection={this.handleUpdateSection}
extraInfo={nameExtraInfo}
/>
);
} else {
const describe = this.state.name;
nameSection = (
<SettingItemMin
title={localizeMessage('general_tab.teamName', 'Team Name')}
describe={describe}
updateSection={this.handleUpdateSection}
section={'name'}
/>
);
}
let descriptionSection;
if (this.props.activeSection === 'description') {
const inputs = [];
const teamDescriptionLabel = this.props.isMobileView ? '' : (
<FormattedMessage
id='general_tab.teamDescription'
defaultMessage='Team Description'
/>
);
inputs.push(
<div
key='teamDescriptionSetting'
className='form-group'
>
<label className='col-sm-5 control-label'>{teamDescriptionLabel}</label>
<div className='col-sm-7'>
<input
id='teamDescription'
autoFocus={true}
className='form-control'
type='text'
maxLength={Constants.MAX_TEAMDESCRIPTION_LENGTH}
onChange={this.updateDescription}
value={this.state.description}
onFocus={moveCursorToEnd}
/>
</div>
</div>,
);
const descriptionExtraInfo = <span>{localizeMessage('general_tab.teamDescriptionInfo', 'Team description provides additional information to help users select the right team. Maximum of 50 characters.')}</span>;
descriptionSection = (
<SettingItemMax
title={localizeMessage('general_tab.teamDescription', 'Team Description')}
inputs={inputs}
submit={this.handleDescriptionSubmit}
serverError={serverError}
clientError={clientError}
updateSection={this.handleUpdateSection}
extraInfo={descriptionExtraInfo}
/>
);
} else {
const describeMsg = this.state.description ?? (
<FormattedMessage
id='general_tab.emptyDescription'
defaultMessage="Click 'Edit' to add a team description."
/>
);
descriptionSection = (
<SettingItemMin
title={localizeMessage('general_tab.teamDescription', 'Team Description')}
describe={describeMsg}
updateSection={this.handleUpdateSection}
section={'description'}
/>
);
}
let teamIconSection;
if (this.props.activeSection === 'team_icon') {
const helpText = (
<FormattedMessage
id='setting_picture.help.team'
defaultMessage='Upload a team icon in BMP, JPG or PNG format.\nSquare images with a solid background color are recommended.'
/>
);
teamIconSection = (
<SettingPicture
imageContext='team'
title={localizeMessage('general_tab.teamIcon', 'Team Icon')}
src={imageURLForTeam(team || {} as Team)}
file={this.state.teamIconFile}
serverError={this.state.serverError}
clientError={this.state.clientError}
loadingPicture={this.state.loadingIcon}
submitActive={this.state.submitActive}
updateSection={(e: MouseEvent<HTMLButtonElement>) => {
this.updateSection('');
e.preventDefault();
}}
onFileChange={this.updateTeamIcon}
onSubmit={this.handleTeamIconSubmit}
onRemove={this.handleTeamIconRemove}
helpText={helpText}
/>
);
} else {
let minMessage;
if (team?.last_team_icon_update) {
minMessage = (
<FormattedMessage
id='general_tab.teamIconLastUpdated'
defaultMessage='Image last updated {date}'
values={{
date: (
<FormattedDate
value={new Date(team.last_team_icon_update)}
day='2-digit'
month='short'
year='numeric'
/>
),
}}
/>
);
} else {
minMessage = this.props.isMobileView ? localizeMessage('general_tab.teamIconEditHintMobile', 'Click to upload an image') : localizeMessage('general_tab.teamIconEditHint', 'Click \'Edit\' to upload an image.');
}
teamIconSection = (
<SettingItemMin
title={localizeMessage('general_tab.teamIcon', 'Team Icon')}
describe={minMessage}
section={'team_icon'}
updateSection={this.handleUpdateSection}
/>
);
}
let allowedDomainsSection;
if (this.props.activeSection === 'allowed_domains') {
const inputs = [];
inputs.push(
<div
key='allowedDomainsSetting'
className='form-group'
>
<div className='col-sm-12'>
<input
id='allowedDomains'
autoFocus={true}
className='form-control'
type='text'
onChange={this.updateAllowedDomains}
value={this.state.allowed_domains}
onFocus={moveCursorToEnd}
placeholder={this.props.intl.formatMessage({id: 'general_tab.AllowedDomainsExample', defaultMessage: 'corp.mattermost.com, mattermost.com'})}
aria-label={localizeMessage('general_tab.allowedDomains.ariaLabel', 'Allowed Domains')}
/>
</div>
</div>,
);
const allowedDomainsInfo = <span>{localizeMessage('general_tab.AllowedDomainsInfo', 'Users can only join the team if their email matches a specific domain (e.g. "mattermost.com") or list of comma-separated domains (e.g. "corp.mattermost.com, mattermost.com").')}</span>;
allowedDomainsSection = (
<SettingItemMax
title={localizeMessage('general_tab.allowedDomains', 'Allow only users with a specific email domain to join this team')}
inputs={inputs}
submit={this.handleAllowedDomainsSubmit}
serverError={serverError}
clientError={clientError}
updateSection={this.handleUpdateSection}
extraInfo={allowedDomainsInfo}
/>
);
} else {
const describeMsg = this.state.allowed_domains ?? (
<FormattedMessage
id='general_tab.allowedDomainsEdit'
defaultMessage="Click 'Edit' to add an email domain whitelist."
/>
);
allowedDomainsSection = (
<SettingItemMin
title={localizeMessage('general_tab.allowedDomains', 'allowedDomains')}
describe={describeMsg}
updateSection={this.handleUpdateSection}
section={'allowed_domains'}
/>
);
}
return (
<div>
<div className='modal-header'>
<button
id='closeButton'
type='button'
className='close'
data-dismiss='modal'
aria-label='Close'
onClick={this.props.closeModal}
>
<span aria-hidden='true'>{'×'}</span>
</button>
<h4 className='modal-title'>
<div className='modal-back'>
<span onClick={this.props.collapseModal}>
<BackIcon/>
</span>
</div>
<FormattedMessage
id='general_tab.title'
defaultMessage='General Settings'
/>
</h4>
</div>
<div className='user-settings'>
<div className='GeneralTab__header'>
<h3>
<FormattedMessage
id='general_tab.title'
defaultMessage='General Settings'
/>
</h3>
<LearnAboutTeamsLink/>
</div>
<div className='divider-dark first'/>
{nameSection}
<div className='divider-light'/>
{descriptionSection}
<div className='divider-light'/>
{teamIconSection}
{!team?.group_constrained &&
<>
<div className='divider-light'/>
{allowedDomainsSection}
</>
}
<div className='divider-light'/>
<OpenInvite
teamId={this.props.team?.id}
isActive={this.props.activeSection === 'open_invite'}
isGroupConstrained={this.props.team?.group_constrained}
allowOpenInvite={this.props.team?.allow_open_invite}
onToggle={this.onOpenInviteToggle}
patchTeam={this.props.actions.patchTeam}
/>
{!team?.group_constrained &&
<>
<div className='divider-light'/>
{inviteSection}
</>
}
<div className='divider-dark'/>
</div>
</div>
);
}
}
export default injectIntl(GeneralTab);

View File

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import SelectTextInput, {type SelectTextInputOption} from 'components/common/select_text_input/select_text_input';
import CheckboxSettingItem from 'components/widgets/modals/components/checkbox_setting_item';
import {type SaveChangesPanelState} from 'components/widgets/modals/components/save_changes_panel';
const translations = defineMessages({
AllowedDomainsTitle: {
id: 'general_tab.AllowedDomainsTitle',
defaultMessage: 'Users with a specific email domain',
},
AllowedDomainsInfo: {
id: 'general_tab.AllowedDomainsInfo',
defaultMessage: 'When enabled, users can only join the team if their email matches a specific domain (e.g. "mattermost.org")',
},
AllowedDomains: {
id: 'general_tab.allowedDomains',
defaultMessage: 'Allow only users with a specific email domain to join this team',
},
});
type Props = {
allowedDomains: string[];
setAllowedDomains: (domains: string[]) => void;
setHasChanges: (hasChanges: boolean) => void;
setSaveChangesPanelState: (state: SaveChangesPanelState) => void;
}
const AllowedDomainsSelect = ({allowedDomains, setAllowedDomains, setHasChanges, setSaveChangesPanelState}: Props) => {
const [showAllowedDomains, setShowAllowedDomains] = useState<boolean>(allowedDomains.length > 0);
const {formatMessage} = useIntl();
const handleEnableAllowedDomains = useCallback((enabled: boolean) => {
setShowAllowedDomains(enabled);
if (!enabled) {
setAllowedDomains([]);
}
}, [setAllowedDomains]);
const updateAllowedDomains = useCallback((domain: string) => {
setHasChanges(true);
setSaveChangesPanelState('editing');
setAllowedDomains([...allowedDomains, domain]);
}, [allowedDomains, setAllowedDomains, setHasChanges, setSaveChangesPanelState]);
const handleOnChangeDomains = useCallback((allowedDomainsOptions?: SelectTextInputOption[] | null) => {
setHasChanges(true);
setSaveChangesPanelState('editing');
setAllowedDomains(allowedDomainsOptions?.map((domain) => domain.value) || []);
}, [setAllowedDomains, setHasChanges, setSaveChangesPanelState]);
return (
<>
<CheckboxSettingItem
data-testid='allowedDomainsCheckbox'
className='access-allowed-domains-section'
title={translations.AllowedDomainsTitle}
description={translations.AllowedDomainsInfo}
descriptionAboveContent={true}
inputFieldData={{title: translations.AllowedDomains, name: 'name'}}
inputFieldValue={showAllowedDomains}
handleChange={handleEnableAllowedDomains}
/>
{showAllowedDomains &&
<SelectTextInput
id='allowedDomains'
placeholder={formatMessage({id: 'general_tab.AllowedDomainsExample', defaultMessage: 'corp.mattermost.com, mattermost.com'})}
aria-label={formatMessage({id: 'general_tab.allowedDomains.ariaLabel', defaultMessage: 'Allowed Domains'})}
value={allowedDomains}
onChange={handleOnChangeDomains}
handleNewSelection={updateAllowedDomains}
isClearable={false}
description={formatMessage({id: 'general_tab.AllowedDomainsTip', defaultMessage: 'Seperate multiple domains with a space, comma, tab or enter.'})}
/>
}
</>
);
};
export default AllowedDomainsSelect;

View File

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import type {ConnectedProps} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import type {Team} from '@mattermost/types/teams';
import {patchTeam, regenerateTeamInviteId} from 'mattermost-redux/actions/teams';
import TeamAccessTab from './team_access_tab';
export type OwnProps = {
team: Team;
hasChanges: boolean;
hasChangeTabError: boolean;
setHasChanges: (hasChanges: boolean) => void;
setHasChangeTabError: (hasChangesError: boolean) => void;
closeModal: () => void;
collapseModal: () => void;
};
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
patchTeam,
regenerateTeamInviteId,
}, dispatch),
};
}
const connector = connect(null, mapDispatchToProps);
export type PropsFromRedux = ConnectedProps<typeof connector>;
export default connector(TeamAccessTab);

View File

@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState, useEffect} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {RefreshIcon} from '@mattermost/compass-icons/components';
import type {Team} from '@mattermost/types/teams';
import {Permissions} from 'mattermost-redux/constants';
import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import type {ActionResult} from 'mattermost-redux/types/actions';
import Input from 'components/widgets/inputs/input/input';
import type {BaseSettingItemProps} from 'components/widgets/modals/components/base_setting_item';
import BaseSettingItem from 'components/widgets/modals/components/base_setting_item';
import type {GlobalState} from 'types/store';
const translations = defineMessages({
OpenInviteDescriptionError: {
id: 'team_settings.openInviteDescription.error',
defaultMessage: 'There was an error generating the invite code, please try again',
},
CodeTitle: {
id: 'general_tab.codeTitle',
defaultMessage: 'Invite Code',
},
CodeLongDesc: {
id: 'general_tab.codeLongDesc',
defaultMessage: 'The Invite Code is part of the unique team invitation link which is sent to members youre inviting to this team. Regenerating the code creates a new invitation link and invalidates the previous link.',
},
});
type Props = {
regenerateTeamInviteId: (teamId: string) => Promise<ActionResult>;
}
const InviteSectionInput = ({regenerateTeamInviteId}: Props) => {
const team = useSelector((state: GlobalState) => getCurrentTeam(state));
const canInviteTeamMembers = useSelector((state: GlobalState) => haveITeamPermission(state, team?.id || '', Permissions.INVITE_USER));
const [inviteId, setInviteId] = useState<Team['invite_id']>(team?.invite_id ?? '');
const [inviteIdError, setInviteIdError] = useState<BaseSettingItemProps['error'] | undefined>();
const {formatMessage} = useIntl();
useEffect(() => {
setInviteId(team?.invite_id || '');
}, [team?.invite_id]);
const handleRegenerateInviteId = useCallback(async () => {
const {data, error} = await regenerateTeamInviteId(team?.id || '');
if (data?.invite_id) {
setInviteId(data.invite_id);
return;
}
if (error) {
setInviteIdError(translations.OpenInviteDescriptionError);
}
}, [regenerateTeamInviteId, team?.id]);
if (!canInviteTeamMembers) {
return null;
}
const inviteSectionInput = (
<div
data-testid='teamInviteContainer'
id='teamInviteContainer'
>
<Input
id='teamInviteId'
type='text'
value={inviteId}
maxLength={32}
/>
<button
data-testid='regenerateButton'
id='regenerateButton'
className='btn btn-tertiary'
onClick={handleRegenerateInviteId}
>
<RefreshIcon/>
{formatMessage({id: 'general_tab.regenerate', defaultMessage: 'Regenerate'})}
</button>
</div>
);
return (
<BaseSettingItem
className='access-invite-section'
title={translations.CodeTitle}
description={translations.CodeLongDesc}
content={inviteSectionInput}
error={inviteIdError}
descriptionAboveContent={true}
/>
);
};
export default InviteSectionInput;

View File

@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, screen} from '@testing-library/react';
import React, {type ComponentProps} from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import OpenInvite from './open_invite';
describe('components/TeamSettings/OpenInvite', () => {
const setAllowOpenInvite = jest.fn().mockReturnValue({data: true});
const defaultProps: ComponentProps<typeof OpenInvite> = {
isGroupConstrained: false,
allowOpenInvite: false,
setAllowOpenInvite,
};
test('should render correct title and link when the team is constrained', () => {
const props = {...defaultProps, isGroupConstrained: true};
renderWithContext(<OpenInvite {...props}/>);
const title = screen.getByText('Users on this server');
expect(title).toBeInTheDocument();
const externalLink = screen.getByText('Learn More');
expect(externalLink).toBeInTheDocument();
expect(externalLink).toHaveAttribute('href', 'https://mattermost.com/pl/default-ldap-group-constrained-team-channel.html?utm_source=mattermost&utm_medium=in-product&utm_content=open_invite&uid=&sid=');
});
test('should render the checkbox when the team is not constrained and not checked', () => {
renderWithContext(<OpenInvite {...defaultProps}/>);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeInTheDocument();
expect(checkbox).not.toBeChecked();
});
test('should render the checkbox when the team is not constrained and checked', () => {
const props = {...defaultProps, allowOpenInvite: true};
renderWithContext(<OpenInvite {...props}/>);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeInTheDocument();
expect(checkbox).toBeChecked();
});
test('should call setAllowOpenInvite when the checkbox is clicked', () => {
renderWithContext(<OpenInvite {...defaultProps}/>);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeInTheDocument();
expect(checkbox).not.toBeChecked();
fireEvent.click(checkbox);
expect(setAllowOpenInvite).toHaveBeenCalledTimes(1);
expect(setAllowOpenInvite).toHaveBeenCalledWith(true);
});
});

View File

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {defineMessages, useIntl} from 'react-intl';
import ExternalLink from 'components/external_link';
import BaseSettingItem from 'components/widgets/modals/components/base_setting_item';
import CheckboxSettingItem from 'components/widgets/modals/components/checkbox_setting_item';
type Props = {
allowOpenInvite: boolean;
isGroupConstrained?: boolean;
setAllowOpenInvite: (value: boolean) => void;
};
const translations = defineMessages({
OpenInviteText: {
id: 'general_tab.openInviteText',
defaultMessage: 'Users on this server',
},
OpenInviteDesc: {
id: 'general_tab.openInviteDesc',
defaultMessage: 'When enabled, a link to this team will be included on the landing page allowing anyone with an account to join this team. Changing this setting will create a new invitation link and invalidate the previous link.',
},
OpenInviteTitle: {
id: 'general_tab.openInviteTitle',
defaultMessage: 'Allow only users with a specific email domain to join this team',
},
});
const OpenInvite = ({isGroupConstrained, allowOpenInvite, setAllowOpenInvite}: Props) => {
const {formatMessage} = useIntl();
if (isGroupConstrained) {
const groupConstrainedContent = (
<p id='groupConstrainedContent' >{
formatMessage({
id: 'team_settings.openInviteDescription.groupConstrained',
defaultMessage: 'Members of this team are added and removed by linked groups. <link>Learn More</link>',
}, {
link: (msg: React.ReactNode) => (
<ExternalLink
href='https://mattermost.com/pl/default-ldap-group-constrained-team-channel.html'
location='open_invite'
>
{msg}
</ExternalLink>
),
})}
</p>
);
return (
<BaseSettingItem
className='access-invite-domains-section'
title={translations.OpenInviteText}
description={translations.OpenInviteDesc}
descriptionAboveContent={true}
content={groupConstrainedContent}
/>
);
}
return (
<CheckboxSettingItem
className='access-invite-domains-section'
inputFieldData={{title: translations.OpenInviteTitle, name: 'name'}}
inputFieldValue={allowOpenInvite}
handleChange={setAllowOpenInvite}
title={translations.OpenInviteText}
description={translations.OpenInviteDesc}
descriptionAboveContent={true}
/>
);
};
export default OpenInvite;

View File

@ -0,0 +1,34 @@
.modal-access-tab-content {
.divider-light {
margin: 24px 0;
}
.mm-modal-generic-section-item__content {
margin-top: 24px;
}
#allowedDomains {
margin-top: 16px;
}
.access-invite-section {
gap: 0;
#teamInviteContainer {
display: flex;
align-items: center;
.Input_container {
margin-right: 8px;
#teamInviteId {
background-color: unset;
}
}
}
}
}
.modal-header {
background-color: var(--center-channel-bg);
}

View File

@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ComponentProps} from 'react';
import {act} from 'react-dom/test-utils';
import {Permissions} from 'mattermost-redux/constants';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import AccessTab from './team_access_tab';
describe('components/TeamSettings', () => {
const getTeam = jest.fn().mockResolvedValue({data: true});
const patchTeam = jest.fn().mockReturnValue({data: true});
const regenerateTeamInviteId = jest.fn().mockReturnValue({data: true});
const removeTeamIcon = jest.fn().mockReturnValue({data: true});
const setTeamIcon = jest.fn().mockReturnValue({data: true});
const baseActions = {
getTeam,
patchTeam,
regenerateTeamInviteId,
removeTeamIcon,
setTeamIcon,
};
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(),
collapseModal: jest.fn(),
};
test('should not render team invite section if no permissions for team inviting', () => {
const props = {...defaultProps, canInviteTeamMembers: false};
renderWithContext(<AccessTab {...props}/>);
const inviteContainer = screen.queryByTestId('teamInviteContainer');
expect(inviteContainer).toBeNull();
});
test('should call regenerateTeamInviteId on handleRegenerateInviteId', () => {
const state = {
entities: {
roles: {
roles: {
team_admin: {
name: 'team_admin',
permissions: [Permissions.INVITE_USER],
},
},
},
users: {
profiles: {
test_user: TestHelper.getUserMock({id: 'test_user', roles: 'team_admin'}),
},
currentUserId: 'test_user',
},
teams: {
currentTeamId: 'team_id',
teams: {
team_id: {...defaultProps.team},
},
},
},
};
const wrapper = renderWithContext(<AccessTab {...defaultProps}/>, state);
wrapper.getByTestId('regenerateButton').click();
expect(baseActions.regenerateTeamInviteId).toHaveBeenCalledTimes(1);
expect(baseActions.regenerateTeamInviteId).toHaveBeenCalledWith(defaultProps.team?.id);
});
test('should not render allowed domains checkbox if no permissions for team inviting', () => {
const props = {...defaultProps, canInviteTeamMembers: false};
renderWithContext(<AccessTab {...props}/>);
const allowedDomainsCheckbox = screen.queryByTestId('allowedDomainsCheckbox');
expect(allowedDomainsCheckbox).toBeNull();
});
test('should not show allowed domains input if allowed domains is empty', () => {
const props = {...defaultProps, team: TestHelper.getTeamMock({allowed_domains: ''})};
renderWithContext(<AccessTab {...props}/>);
const allowedDomainsInput = screen.queryByText('Seperate multiple domains with a space, comma, tab or enter.');
expect(allowedDomainsInput).toBeNull();
});
test('should show allowed domains input if allowed domains is not empty', () => {
const props = {...defaultProps, team: TestHelper.getTeamMock({allowed_domains: 'test.com'})};
renderWithContext(<AccessTab {...props}/>);
const allowedDomainsInput = screen.getByText('Seperate multiple domains with a space, comma, tab or enter.');
expect(allowedDomainsInput).toBeInTheDocument();
const allowedDomainsInputValue = screen.getByText('test.com');
expect(allowedDomainsInputValue).toBeInTheDocument();
});
test('should call patchTeam on handleAllowedDomainsSubmit', async () => {
const props = {...defaultProps, team: TestHelper.getTeamMock({allowed_domains: 'test.com'})};
renderWithContext(<AccessTab {...props}/>);
const allowedDomainsInput = screen.getAllByRole('textbox')[0];
const newDomain = 'best.com';
await act(async () => {
await allowedDomainsInput.focus();
await userEvent.type(allowedDomainsInput, `${newDomain},`);
});
const newDomainText = screen.getByText(newDomain);
expect(newDomainText).toBeInTheDocument();
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
expect(baseActions.patchTeam).toHaveBeenCalledTimes(1);
expect(baseActions.patchTeam).toHaveBeenCalledWith({
allowed_domains: 'test.com, best.com',
id: defaultProps.team?.id,
});
});
});

View File

@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// 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';
import InviteSectionInput from './invite_section_input';
import OpenInvite from './open_invite';
import type {PropsFromRedux, OwnProps} from '.';
import './team_access_tab.scss';
const generateAllowedDomainOptions = (allowedDomains?: string) => {
if (!allowedDomains || allowedDomains.length === 0) {
return [];
}
const domainList = allowedDomains.includes(',') ? allowedDomains.split(',') : [allowedDomains];
return domainList.map((domain) => domain.trim());
};
type Props = PropsFromRedux & OwnProps;
const AccessTab = ({closeModal, collapseModal, hasChangeTabError, hasChanges, setHasChangeTabError, setHasChanges, 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({
id: team.id,
allowed_domains: allowedDomains.length === 1 ? allowedDomains[0] : allowedDomains.join(', '),
});
if (error) {
return false;
}
return true;
}, [actions, allowedDomains, team]);
const handleOpenInviteSubmit = useCallback(async (): Promise<boolean> => {
if (allowOpenInvite === team.allow_open_invite) {
return true;
}
const data = {
id: team.id,
allow_open_invite: allowOpenInvite,
};
const {error} = await actions.patchTeam(data);
if (error) {
return false;
}
return true;
}, [actions, allowOpenInvite, team]);
const updateOpenInvite = useCallback((value: boolean) => {
setHasChanges(true);
setSaveChangesPanelState('editing');
setAllowOpenInvite(value);
}, [setHasChanges]);
const handleClose = useCallback(() => {
setSaveChangesPanelState('editing');
setHasChanges(false);
setHasChangeTabError(false);
}, [setHasChangeTabError, setHasChanges]);
const handleCancel = useCallback(() => {
setAllowedDomains(generateAllowedDomainOptions(team.allowed_domains));
setAllowOpenInvite(team.allow_open_invite ?? false);
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();
if (!allowedDomainSuccess || !openInviteSuccess) {
setSaveChangesPanelState('error');
return;
}
setSaveChangesPanelState('saved');
setHasChangeTabError(false);
}, [handleAllowedDomainsSubmit, handleOpenInviteSubmit, setHasChangeTabError]);
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: 'Collapes 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'>
{team.group_constrained ?
undefined :
<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 ?
undefined :
<InviteSectionInput regenerateTeamInviteId={actions.regenerateTeamInviteId}/>
}
{hasChanges ?
<SaveChangesPanel
handleCancel={handleCancel}
handleSubmit={handleSaveChanges}
handleClose={handleClose}
tabChangeError={hasChangeTabError}
state={saveChangesPanelState}
/> : undefined}
</div>
</>
}
/>
);
};
export default AccessTab;

View File

@ -8,35 +8,29 @@ import type {Dispatch} from 'redux';
import type {Team} from '@mattermost/types/teams';
import {getTeam, patchTeam, removeTeamIcon, setTeamIcon, regenerateTeamInviteId} from 'mattermost-redux/actions/teams';
import {Permissions} from 'mattermost-redux/constants';
import {getTeam, patchTeam, removeTeamIcon, setTeamIcon} from 'mattermost-redux/actions/teams';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {getIsMobileView} from 'selectors/views/browser';
import type {GlobalState} from 'types/store/index';
import TeamGeneralTab from './team_general_tab';
import TeamInfoTab from './team_info_tab';
export type OwnProps = {
updateSection: (section: string) => void;
team: Team & { last_team_icon_update?: number };
activeSection: string;
team: Team;
hasChanges: boolean;
hasChangeTabError: boolean;
setHasChanges: (hasChanges: boolean) => void;
setHasChangeTabError: (hasChangesError: boolean) => void;
closeModal: () => void;
collapseModal: () => void;
};
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const maxFileSize = parseInt(config.MaxFileSize ?? '', 10);
const canInviteTeamMembers = haveITeamPermission(state, ownProps.team?.id || '', Permissions.INVITE_USER);
return {
maxFileSize,
canInviteTeamMembers,
isMobileView: getIsMobileView(state),
};
}
@ -45,7 +39,6 @@ function mapDispatchToProps(dispatch: Dispatch) {
actions: bindActionCreators({
getTeam,
patchTeam,
regenerateTeamInviteId,
removeTeamIcon,
setTeamIcon,
}, dispatch),
@ -56,4 +49,4 @@ const connector = connect(mapStateToProps, mapDispatchToProps);
export type PropsFromRedux = ConnectedProps<typeof connector>;
export default connector(TeamGeneralTab);
export default connector(TeamInfoTab);

View File

@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import type {ChangeEvent} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import type {Team} from '@mattermost/types/teams';
import Input from 'components/widgets/inputs/input/input';
import BaseSettingItem, {type BaseSettingItemProps} from 'components/widgets/modals/components/base_setting_item';
import Constants from 'utils/constants';
const translations = defineMessages({
TeamDescriptionInfo: {
id: 'general_tab.teamDescriptionInfo',
defaultMessage: 'Team description provides additional information to help users select the right team. Maximum of 50 characters.',
},
});
type Props = {
handleDescriptionChanges: (name: string) => void;
description: Team['description'];
clientError?: BaseSettingItemProps['error'];
};
const TeamDescriptionSection = ({handleDescriptionChanges, clientError, description}: Props) => {
const {formatMessage} = useIntl();
const updateDescription = useCallback((e: ChangeEvent<HTMLInputElement>) => {
handleDescriptionChanges(e.target.value);
}, [handleDescriptionChanges]);
const descriptionSectionInput = (
<Input
id='teamDescription'
data-testid='teamDescriptionInput'
containerClassName='description-section-input'
type='textarea'
maxLength={Constants.MAX_TEAMDESCRIPTION_LENGTH}
onChange={updateDescription}
value={description}
label={formatMessage({id: 'general_tab.teamDescription', defaultMessage: 'Description'})}
/>
);
return (
<BaseSettingItem
description={translations.TeamDescriptionInfo}
content={descriptionSectionInput}
className='description-setting-item'
error={clientError}
/>
);
};
export default TeamDescriptionSection;

View File

@ -0,0 +1,26 @@
.modal-info-tab-content {
display: flex;
.name-description-container {
flex: 2;
}
.description-section-input {
fieldset {
height: 100%;
}
}
#teamName,
#teamDescription {
background-color: unset;
}
.description-setting-item {
margin-top: 24px;
}
}
.modal-header {
background-color: var(--center-channel-bg);
}

View File

@ -0,0 +1,183 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import type {ComponentProps} from 'react';
import {act} from 'react-dom/test-utils';
import {renderWithContext, userEvent} from 'tests/react_testing_utils';
import Constants from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import InfoTab from './team_info_tab';
describe('components/TeamSettings', () => {
const getTeam = jest.fn().mockResolvedValue({data: true});
const patchTeam = jest.fn().mockReturnValue({data: true});
const removeTeamIcon = jest.fn().mockReturnValue({data: true});
const setTeamIcon = jest.fn().mockReturnValue({data: true});
const baseActions = {
getTeam,
patchTeam,
removeTeamIcon,
setTeamIcon,
};
const defaultProps: ComponentProps<typeof InfoTab> = {
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(),
closeModal: jest.fn(),
collapseModal: jest.fn(),
};
beforeEach(() => {
global.URL.createObjectURL = jest.fn();
});
test('should show an error when pdf file is uploaded', () => {
renderWithContext(<InfoTab {...defaultProps}/>);
const file = new File(['pdf'], 'pdf.pdf', {type: 'application/pdf'});
const input = screen.getByTestId('uploadPicture');
userEvent.upload(input, file);
const error = screen.getByTestId('mm-modal-generic-section-item__error');
expect(error).toBeVisible();
expect(error).toHaveTextContent('Only BMP, JPG or PNG images may be used for team icons');
});
test('should show an error when too large file is uploaded with 40mb', () => {
renderWithContext(<InfoTab {...defaultProps}/>);
const file = new File(['test'], 'test.png', {type: 'image/png'});
Object.defineProperty(file, 'size', {value: defaultProps.maxFileSize + 1});
const input = screen.getByTestId('uploadPicture');
userEvent.upload(input, file);
const error = screen.getByTestId('mm-modal-generic-section-item__error');
expect(error).toBeVisible();
expect(error).toHaveTextContent('Unable to upload team icon. File is too large.');
});
test('should call setTeamIcon when an image is uploaded and saved', async () => {
renderWithContext(<InfoTab {...defaultProps}/>);
const file = new File(['test'], 'test.png', {type: 'image/png'});
const input = screen.getByTestId('uploadPicture');
await act(async () => {
userEvent.upload(input, file);
});
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
expect(setTeamIcon).toHaveBeenCalledTimes(1);
expect(setTeamIcon).toHaveBeenCalledWith(defaultProps.team?.id, file);
});
test('should call setTeamIcon when an image is removed', async () => {
renderWithContext(<InfoTab {...defaultProps}/>);
const file = new File(['test'], 'test.png', {type: 'image/png'});
const input = screen.getByTestId('uploadPicture');
await act(async () => {
userEvent.upload(input, file);
});
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
const removeImageButton = screen.getByTestId('removeImageButton');
await act(async () => {
userEvent.click(removeImageButton);
});
expect(removeTeamIcon).toHaveBeenCalledTimes(1);
expect(removeTeamIcon).toHaveBeenCalledWith(defaultProps.team?.id);
});
test('should show an error when team name is empty', async () => {
renderWithContext(<InfoTab {...defaultProps}/>);
const input = screen.getByTestId('teamNameInput');
act(() => {
userEvent.clear(input);
});
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
const error = screen.getByTestId('mm-modal-generic-section-item__error');
expect(error).toBeVisible();
expect(error).toHaveTextContent('This field is required');
});
test('should show an error when team name is too short', async () => {
renderWithContext(<InfoTab {...defaultProps}/>);
const input = screen.getByTestId('teamNameInput');
await act(async () => {
await userEvent.clear(input);
await userEvent.type(input, 'a');
});
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
const error = screen.getByTestId('mm-modal-generic-section-item__error');
expect(error).toBeVisible();
expect(error).toHaveTextContent(`Team Name must be ${Constants.MIN_TEAMNAME_LENGTH} or more characters up to a maximum of ${Constants.MAX_TEAMNAME_LENGTH}. You can add a longer team description.`);
});
test('should call patchTeam when team name is changed and clicked saved', async () => {
renderWithContext(<InfoTab {...defaultProps}/>);
const input = screen.getByTestId('teamNameInput');
userEvent.clear(input);
userEvent.type(input, 'new_team_name');
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
expect(patchTeam).toHaveBeenCalledTimes(1);
expect(patchTeam).toHaveBeenCalledWith({id: defaultProps.team?.id, display_name: 'new_team_name', description: defaultProps.team?.description});
});
test('should call patchTeam when team description is changed and clicked saved', async () => {
renderWithContext(<InfoTab {...defaultProps}/>);
const input = screen.getByTestId('teamDescriptionInput');
await act(async () => {
await userEvent.clear(input);
await userEvent.type(input, 'new_team_description');
});
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
expect(patchTeam).toHaveBeenCalledTimes(1);
expect(patchTeam).toHaveBeenCalledWith({id: defaultProps.team?.id, display_name: defaultProps.team?.display_name, description: 'new_team_description'});
});
test('should call patchTeam when team name and description are change and clicked saved', async () => {
renderWithContext(<InfoTab {...defaultProps}/>);
const nameInput = screen.getByTestId('teamNameInput');
const descriptionInput = screen.getByTestId('teamDescriptionInput');
userEvent.clear(nameInput);
userEvent.type(nameInput, 'new_team_name');
userEvent.clear(descriptionInput);
userEvent.type(descriptionInput, 'new_team_description');
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
expect(patchTeam).toHaveBeenCalledTimes(1);
expect(patchTeam).toHaveBeenCalledWith({id: defaultProps.team?.id, display_name: 'new_team_name', description: 'new_team_description'});
});
});

View File

@ -0,0 +1,236 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import type {ChangeEvent} from 'react';
import {defineMessages, useIntl} 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';
import TeamDescriptionSection from './team_description_section';
import TeamNameSection from './team_name_section';
import TeamPictureSection from './team_picture_section';
import type {PropsFromRedux, OwnProps} from '.';
import './team_info_tab.scss';
const ACCEPTED_TEAM_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/bmp'];
const translations = defineMessages({
Required: {
id: 'general_tab.required',
defaultMessage: 'This field is required',
},
TeamNameRestrictions: {
id: 'general_tab.teamNameRestrictions',
defaultMessage: 'Team Name must be {min} or more characters up to a maximum of {max}. You can add a longer team description.',
values: {min: Constants.MIN_TEAMNAME_LENGTH, max: Constants.MAX_TEAMNAME_LENGTH},
},
TeamIconInvalidFileType: {
id: 'general_tab.teamIconInvalidFileType',
defaultMessage: 'Only BMP, JPG or PNG images may be used for team icons',
},
TeamIconTooLarge: {
id: 'general_tab.teamIconTooLarge',
defaultMessage: 'Unable to upload team icon. File is too large.',
},
TeamIconError: {
id: 'general_tab.teamIconError',
defaultMessage: 'An error occurred while selecting the image.',
},
});
type Props = PropsFromRedux & OwnProps;
const InfoTab = ({team, hasChanges, maxFileSize, closeModal, collapseModal, hasChangeTabError, setHasChangeTabError, setHasChanges, 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>();
const [loading, setLoading] = useState<boolean>(false);
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) {
return true;
}
if (!name) {
setNameClientError(translations.Required);
return false;
} else if (name.length < Constants.MIN_TEAMNAME_LENGTH) {
setNameClientError(translations.TeamNameRestrictions);
return false;
}
setNameClientError(undefined);
const {error} = await actions.patchTeam({id: team.id, display_name: name, description});
if (error) {
return false;
}
return true;
}, [actions, description, name, team.description, team.display_name, team.id]);
const handleTeamIconSubmit = useCallback(async (): Promise<boolean> => {
if (!teamIconFile) {
return true;
}
setLoading(true);
setImageClientError(undefined);
const {error} = await actions.setTeamIcon(team.id, teamIconFile);
setLoading(false);
if (error) {
return false;
}
return true;
}, [actions, team, teamIconFile]);
const handleSaveChanges = useCallback(async () => {
const nameDescriptionSuccess = await handleNameDescriptionSubmit();
const teamIconSuccess = await handleTeamIconSubmit();
if (!teamIconSuccess || !nameDescriptionSuccess) {
setSaveChangesPanelState('error');
return;
}
setSaveChangesPanelState('saved');
setHasChangeTabError(false);
}, [handleNameDescriptionSubmit, handleTeamIconSubmit, setHasChangeTabError]);
const handleClose = useCallback(() => {
setSaveChangesPanelState('editing');
setHasChanges(false);
setHasChangeTabError(false);
}, [setHasChangeTabError, setHasChanges]);
const handleCancel = useCallback(() => {
setName(team.display_name ?? team.name);
setDescription(team.description);
setTeamIconFile(undefined);
setImageClientError(undefined);
setNameClientError(undefined);
handleClose();
}, [handleClose, team.description, team.display_name, team.name]);
const handleTeamIconRemove = useCallback(async () => {
setLoading(true);
setImageClientError(undefined);
setTeamIconFile(undefined);
handleClose();
const {error} = await actions.removeTeamIcon(team.id);
setLoading(false);
if (error) {
setSaveChangesPanelState('error');
setHasChanges(true);
setHasChangeTabError(true);
}
}, [actions, handleClose, setHasChangeTabError, setHasChanges, team.id]);
const updateTeamIcon = useCallback((e: ChangeEvent<HTMLInputElement>) => {
if (e && e.target && e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (!ACCEPTED_TEAM_IMAGE_TYPES.includes(file.type)) {
setImageClientError(translations.TeamIconInvalidFileType);
} else if (file.size > maxFileSize) {
setImageClientError(translations.TeamIconTooLarge);
} else {
setTeamIconFile(file);
setImageClientError(undefined);
setSaveChangesPanelState('editing');
setHasChanges(true);
}
} else {
setTeamIconFile(undefined);
setImageClientError(translations.TeamIconError);
}
}, [maxFileSize, setHasChanges]);
const handleNameChanges = useCallback((name: string) => {
setHasChanges(true);
setSaveChangesPanelState('editing');
setName(name);
}, [setHasChanges]);
const handleDescriptionChanges = useCallback((description: string) => {
setHasChanges(true);
setSaveChangesPanelState('editing');
setDescription(description);
}, [setHasChanges]);
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: 'Collapes 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' >
<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}
/>
{hasChanges ?
<SaveChangesPanel
handleCancel={handleCancel}
handleSubmit={handleSaveChanges}
handleClose={handleClose}
tabChangeError={hasChangeTabError}
state={saveChangesPanelState}
/> : undefined}
</div>
</>
);
return <ModalSection content={modalSectionContent}/>;
};
export default InfoTab;

View File

@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import type {ChangeEvent} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import type {Team} from '@mattermost/types/teams';
import Input from 'components/widgets/inputs/input/input';
import BaseSettingItem, {type BaseSettingItemProps} from 'components/widgets/modals/components/base_setting_item';
import Constants from 'utils/constants';
const translations = defineMessages({
TeamInfo: {
id: 'general_tab.teamInfo',
defaultMessage: 'Team info',
},
TeamNameInfo: {
id: 'general_tab.teamNameInfo',
defaultMessage: 'This name will appear on your sign-in screen and at the top of the left sidebar.',
},
});
type Props = {
handleNameChanges: (name: string) => void;
name: Team['display_name'];
clientError: BaseSettingItemProps['error'];
};
const TeamNameSection = ({clientError, handleNameChanges, name}: Props) => {
const {formatMessage} = useIntl();
const updateName = useCallback((e: ChangeEvent<HTMLInputElement>) => handleNameChanges(e.target.value), [handleNameChanges]);
const nameSectionInput = (
<Input
id='teamName'
data-testid='teamNameInput'
type='text'
maxLength={Constants.MAX_TEAMNAME_LENGTH}
onChange={updateName}
value={name}
label={formatMessage({id: 'general_tab.teamName', defaultMessage: 'Team Name'})}
/>
);
return (
<BaseSettingItem
title={translations.TeamInfo}
description={translations.TeamNameInfo}
content={nameSectionInput}
error={clientError}
/>
);
};
export default TeamNameSection;

View File

@ -0,0 +1,61 @@
.picture-setting-item {
flex: 1;
margin-left: 40px;
&__remove-button {
display: flex;
align-items: center;
margin-top: 18px;
color: var(--dnd-indicator);
text-align: left;
svg {
margin-right: 6px;
}
}
.team-picture-section {
position: relative;
width: fit-content;
&__team-icon {
display: flex;
width: 120px;
height: 120px;
align-items: center;
justify-content: center;
background-color: var(--sidebar-header-bg);
border-radius: 24px;
cursor: pointer;
}
&__team-name {
color: #fff;
font-family: Metropolis;
font-size: 56px;
font-style: normal;
font-weight: 600;
line-height: 56px;
text-align: center;
}
.icon-pencil-outline {
position: absolute;
right: -6px;
bottom: -6px;
padding: 4px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
background: var(--center-channel-bg, #fff);
border-radius: 20px;
box-shadow: 0 2px 3px 0 var(--elevation-1);
cursor: pointer;
}
.team-img-preview {
width: 120px;
height: 120px;
border-radius: 24px;
cursor: pointer;
}
}
}

View File

@ -0,0 +1,180 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ChangeEvent, useRef, useState, useEffect, useCallback} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {TrashCanOutlineIcon} from '@mattermost/compass-icons/components';
import type {Team} from '@mattermost/types/teams';
import EditIcon from 'components/widgets/icons/fa_edit_icon';
import BaseSettingItem from 'components/widgets/modals/components/base_setting_item';
import type {BaseSettingItemProps} from 'components/widgets/modals/components/base_setting_item';
import Constants from 'utils/constants';
import * as FileUtils from 'utils/file_utils';
import {imageURLForTeam} from 'utils/utils';
import './team_picture_section.scss';
const translations = defineMessages({
Title: {
id: 'setting_picture.title',
defaultMessage: 'Team Icon',
},
Profile: {
id: 'setting_picture.help.profile',
defaultMessage: 'Upload a picture in BMP, JPG, JPEG, or PNG format. Maximum file size: {max}',
values: {max: '50MB'},
},
});
type Props = {
team: Team;
file?: File | null;
teamName: string;
disabled: boolean;
onFileChange: (e: ChangeEvent<HTMLInputElement>) => void;
onRemove: () => void;
clientError?: BaseSettingItemProps['error'];
};
const TeamPictureSection = ({team, file, teamName, disabled, onFileChange, onRemove, clientError}: Props) => {
const selectInput = useRef<HTMLInputElement>(null);
const [image, setImage] = useState<string>('');
const [orientationStyles, setOrientationStyles] = useState<{transform: string; transformOrigin: string}>();
const {formatMessage} = useIntl();
const teamImageSource = imageURLForTeam(team);
const handleInputFile = useCallback(() => {
if (selectInput.current) {
selectInput.current.value = '';
selectInput.current.click();
}
}, []);
const editIcon = () => {
return (
<>
<input
data-testid='uploadPicture'
ref={selectInput}
className='hidden'
accept={Constants.ACCEPT_STATIC_IMAGE}
disabled={disabled}
type='file'
onChange={onFileChange}
aria-hidden={true}
tabIndex={-1}
/>
<span
disabled={disabled}
onClick={handleInputFile}
>
<EditIcon/>
</span>
</>
);
};
const teamImage = () => {
if (file) {
const imageStyles = {
backgroundImage: 'url(' + image + ')',
backgroundSize: 'cover',
backgroundRepeat: 'round',
...orientationStyles,
};
return (
<div
id='teamIconImage'
alt='team image preview'
style={imageStyles}
className='team-img-preview'
onClick={handleInputFile}
/>
);
}
if (teamImageSource) {
return (
<img
id='teamIconImage'
className='team-img-preview'
src={teamImageSource}
onClick={handleInputFile}
/>
);
}
return (
<div className='team-picture-section__team-icon' >
<span
id='teamIconInitial'
onClick={handleInputFile}
className='team-picture-section__team-name'
>{teamName.charAt(0).toUpperCase() + teamName.charAt(1)}</span>
</div>
);
};
const setPicture = (file: File) => {
if (file) {
const previewBlob = URL.createObjectURL(file);
const reader = new FileReader();
reader.onload = (e) => {
const orientation = FileUtils.getExifOrientation(e.target!.result! as ArrayBuffer);
const orientationStyles = FileUtils.getOrientationStyles(orientation);
setImage(previewBlob);
setOrientationStyles(orientationStyles);
};
reader.readAsArrayBuffer(file);
}
};
useEffect(() => {
if (file) {
setPicture(file);
}
}, [file]);
const removeImageButton = () => {
if (file || teamImageSource) {
return (
<button
onClick={onRemove}
data-testid='removeImageButton'
className='style--none picture-setting-item__remove-button'
>
<TrashCanOutlineIcon/>
{formatMessage({id: 'setting_picture.remove_image', defaultMessage: 'Remove image'})}
</button>
);
}
return null;
};
const teamPictureSection = (
<>
<div className='team-picture-section' >
{teamImage()}
{editIcon()}
</div>
{removeImageButton()}
</>
);
return (
<BaseSettingItem
title={translations.Title}
description={teamImageSource ? undefined : translations.Profile}
content={teamPictureSection}
className='picture-setting-item'
error={clientError}
/>
);
};
export default TeamPictureSection;

View File

@ -5,24 +5,29 @@ import React from 'react';
import type {Team} from '@mattermost/types/teams';
import GeneralTab from 'components/team_general_tab';
import AccessTab from './team_access_tab';
import InfoTab from './team_info_tab';
type Props = {
activeTab: string;
activeSection: string;
updateSection: (section: string) => void;
hasChanges: boolean;
hasChangeTabError: boolean;
setHasChanges: (hasChanges: boolean) => void;
setHasChangeTabError: (hasChangesError: boolean) => void;
closeModal: () => void;
collapseModal: () => void;
team?: Team;
team: Team;
};
const TeamSettings = ({
activeTab = '',
activeSection = '',
updateSection,
closeModal,
collapseModal,
team,
hasChanges,
hasChangeTabError,
setHasChanges,
setHasChangeTabError,
}: Props): JSX.Element | null => {
if (!team) {
return null;
@ -30,17 +35,30 @@ const TeamSettings = ({
let result;
switch (activeTab) {
case 'general':
case 'info':
result = (
<div>
<GeneralTab
team={team}
activeSection={activeSection}
updateSection={updateSection}
closeModal={closeModal}
collapseModal={collapseModal}
/>
</div>
<InfoTab
team={team}
hasChanges={hasChanges}
setHasChanges={setHasChanges}
hasChangeTabError={hasChangeTabError}
setHasChangeTabError={setHasChangeTabError}
closeModal={closeModal}
collapseModal={collapseModal}
/>
);
break;
case 'access':
result = (
<AccessTab
team={team}
hasChanges={hasChanges}
setHasChanges={setHasChanges}
hasChangeTabError={hasChangeTabError}
setHasChangeTabError={setHasChangeTabError}
closeModal={closeModal}
collapseModal={collapseModal}
/>
);
break;
default:

View File

@ -1,93 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/team_settings_modal should match snapshot 1`] = `
<Modal
animation={true}
aria-labelledby="teamSettingsModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal settings-modal settings-modal--action"
dialogComponentClass={[Function]}
enforceFocus={true}
id="teamSettingsModal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[Function]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
id="teamSettingsModalLabel"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
>
<MemoizedFormattedMessage
defaultMessage="Team Settings"
id="team_settings_modal.title"
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<div
className="settings-table"
>
<div
className="settings-links"
>
<Suspense
fallback={null}
>
<lazy
activeTab="general"
tabs={
Array [
Object {
"icon": "icon icon-settings-outline",
"iconTitle": "Settings Icon",
"name": "general",
"uiName": "General",
},
]
}
updateTab={[Function]}
/>
</Suspense>
</div>
<div
className="settings-content minimize-settings"
>
<Connect(TeamSettings)
activeSection=""
activeTab="general"
closeModal={[Function]}
collapseModal={[Function]}
updateSection={[Function]}
/>
</div>
</div>
</ModalBody>
</Modal>
`;

View File

@ -3,8 +3,6 @@
import {connect} from 'react-redux';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {isModalOpen} from 'selectors/views/modals';
import {ModalIdentifiers} from 'utils/constants';
@ -17,7 +15,6 @@ function mapStateToProps(state: GlobalState) {
const modalId = ModalIdentifiers.TEAM_SETTINGS;
return {
show: isModalOpen(state, modalId),
isCloud: getLicense(state).Cloud === 'true',
};
}

View File

@ -1,35 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import {fireEvent, screen} from '@testing-library/react';
import React from 'react';
import TeamSettingsModal from 'components/team_settings_modal/team_settings_modal';
import {renderWithContext} from 'tests/react_testing_utils';
describe('components/team_settings_modal', () => {
const baseProps = {
isCloud: false,
onExited: jest.fn(),
};
test('should match snapshot', () => {
const wrapper = shallow(
test('should hide the modal when the close button is clicked', async () => {
renderWithContext(
<TeamSettingsModal
{...baseProps}
/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should call onExited callback when the modal is hidden', () => {
const wrapper = shallow(
<TeamSettingsModal
{...baseProps}
/>,
);
(wrapper.instance() as TeamSettingsModal).handleHidden();
expect(baseProps.onExited).toHaveBeenCalledTimes(1);
const modal = screen.getByRole('dialog', {name: 'Close Team Settings'});
expect(modal.className).toBe('fade in modal');
fireEvent.click(screen.getByText('Close'));
expect(modal.className).toBe('fade modal');
});
});

View File

@ -1,126 +1,101 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Modal} from 'react-bootstrap';
import React, {useState, useRef, useCallback} from 'react';
import {Modal, type ModalBody} from 'react-bootstrap';
import ReactDOM from 'react-dom';
import {FormattedMessage} from 'react-intl';
import {useIntl} from 'react-intl';
import TeamSettings from 'components/team_settings';
import * as Utils from 'utils/utils';
const SettingsSidebar = React.lazy(() => import('components/settings_sidebar'));
type Props = {
onExited: () => void;
isCloud?: boolean;
}
export type State = {
activeTab: string;
activeSection: string;
show: boolean;
}
const TeamSettingsModal = ({onExited}: 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 modalBodyRef = useRef<ModalBody>(null);
const {formatMessage} = useIntl();
export default class TeamSettingsModal extends React.PureComponent<Props, State> {
modalBodyRef: React.RefObject<Modal>;
const updateTab = useCallback((tab: string) => {
if (hasChanges) {
setHasChangeTabError(true);
return;
}
setActiveTab(tab);
setHasChanges(false);
setHasChangeTabError(false);
}, [hasChanges]);
constructor(props: Props) {
super(props);
const handleHide = useCallback(() => setShow(false), []);
this.state = {
activeTab: 'general',
activeSection: '',
show: true,
};
const handleClose = useCallback(() => {
setActiveTab('info');
setHasChanges(false);
setHasChangeTabError(false);
onExited();
}, [onExited]);
this.modalBodyRef = React.createRef();
}
const handleCollapse = useCallback(() => {
const el = ReactDOM.findDOMNode(modalBodyRef.current) as HTMLDivElement;
el?.closest('.modal-dialog')!.classList.remove('display--content');
setActiveTab('');
}, []);
updateTab = (tab: string) => {
this.setState({
activeTab: tab,
activeSection: '',
});
};
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: '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'})},
];
updateSection = (section: string) => {
this.setState({activeSection: section});
};
collapseModal = () => {
const el = ReactDOM.findDOMNode(this.modalBodyRef.current) as HTMLDivElement;
const modalDialog = el.closest('.modal-dialog');
modalDialog?.classList.remove('display--content');
this.setState({
activeTab: '',
activeSection: '',
});
};
handleHide = () => {
this.setState({show: false});
};
// called after the dialog is fully hidden and faded out
handleHidden = () => {
this.setState({
activeTab: 'general',
activeSection: '',
});
this.props.onExited();
};
render() {
const tabs = [];
tabs.push({name: 'general', uiName: Utils.localizeMessage('team_settings_modal.generalTab', 'General'), icon: 'icon icon-settings-outline', iconTitle: Utils.localizeMessage('generic_icons.settings', 'Settings Icon')});
return (
<Modal
dialogClassName='a11y__modal settings-modal settings-modal--action'
show={this.state.show}
onHide={this.handleHide}
onExited={this.handleHidden}
role='dialog'
aria-labelledby='teamSettingsModalLabel'
id='teamSettingsModal'
return (
<Modal
dialogClassName='a11y__modal settings-modal'
show={show}
onHide={handleHide}
onExited={handleClose}
role='dialog'
aria-labelledby='teamSettingsModalLabel'
id='teamSettingsModal'
>
<Modal.Header
id='teamSettingsModalLabel'
closeButton={true}
>
<Modal.Header
id='teamSettingsModalLabel'
closeButton={true}
>
<Modal.Title componentClass='h1'>
<FormattedMessage
id='team_settings_modal.title'
defaultMessage='Team Settings'
/>
</Modal.Title>
</Modal.Header>
<Modal.Body ref={this.modalBodyRef}>
<div className='settings-table'>
<div className='settings-links'>
<React.Suspense fallback={null}>
<SettingsSidebar
tabs={tabs}
activeTab={this.state.activeTab}
updateTab={this.updateTab}
/>
</React.Suspense>
</div>
<div className='settings-content minimize-settings'>
<TeamSettings
activeTab={this.state.activeTab}
activeSection={this.state.activeSection}
updateSection={this.updateSection}
closeModal={this.handleHide}
collapseModal={this.collapseModal}
<Modal.Title componentClass='h1'>
{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
tabs={tabs}
activeTab={activeTab}
updateTab={updateTab}
/>
</div>
</React.Suspense>
</div>
</Modal.Body>
</Modal>
);
}
}
<div className='settings-content minimize-settings'>
<TeamSettings
activeTab={activeTab}
hasChanges={hasChanges}
setHasChanges={setHasChanges}
hasChangeTabError={hasChangeTabError}
setHasChangeTabError={setHasChangeTabError}
closeModal={handleHide}
collapseModal={handleCollapse}
/>
</div>
</div>
</Modal.Body>
</Modal>
);
};
export default TeamSettingsModal;

View File

@ -17,6 +17,10 @@
.Input_container {
width: 100%;
textarea {
resize: none;
}
.Input {
position: relative;
left: 0;
@ -58,6 +62,10 @@
> :not(:first-child) {
margin-left: 8px;
}
textarea {
margin-top: 6px;
}
}
.Input_limit-exceeded {

View File

@ -21,7 +21,7 @@ export enum SIZE {
export type CustomMessageInputType = {type: 'info' | 'error' | 'warning' | 'success'; value: React.ReactNode} | null;
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
required?: boolean;
hasError?: boolean;
addon?: React.ReactElement;
@ -71,7 +71,7 @@ const Input = React.forwardRef((
onClear,
...otherProps
}: InputProps,
ref?: React.Ref<HTMLInputElement>,
ref?: React.Ref<HTMLInputElement | HTMLTextAreaElement>,
) => {
const {formatMessage} = useIntl();
@ -94,7 +94,7 @@ const Input = React.forwardRef((
}
}, [customMessage]);
const handleOnFocus = (event: React.FocusEvent<HTMLInputElement>) => {
const handleOnFocus = (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFocused(true);
if (onFocus) {
@ -102,7 +102,7 @@ const Input = React.forwardRef((
}
};
const handleOnBlur = (event: React.FocusEvent<HTMLInputElement>) => {
const handleOnBlur = (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFocused(false);
validateInput();
@ -111,7 +111,7 @@ const Input = React.forwardRef((
}
};
const handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleOnChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setCustomInputLabel(null);
if (onChange) {
@ -157,6 +157,45 @@ const Input = React.forwardRef((
</div>
) : null;
const generateInput = () => {
if (otherProps.type === 'textarea') {
return (
<textarea
ref={ref as React.RefObject<HTMLTextAreaElement>}
id={`input_${name || ''}`}
className={classNames('Input form-control', inputSize, inputClassName, {Input__focus: showLegend})}
value={value}
placeholder={focused ? (label && placeholder) || label : label || placeholder}
aria-label={label || placeholder}
rows={3}
name={name}
disabled={disabled}
{...otherProps}
maxLength={limit ? undefined : maxLength}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
onChange={handleOnChange}
/>);
}
return (
<input
ref={ref as React.RefObject<HTMLInputElement>}
id={`input_${name || ''}`}
className={classNames('Input form-control', inputSize, inputClassName, {Input__focus: showLegend})}
value={value}
placeholder={focused ? (label && placeholder) || label : label || placeholder}
aria-label={label || placeholder}
name={name}
disabled={disabled}
{...otherProps}
maxLength={limit ? undefined : maxLength}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
onChange={handleOnChange}
/>
);
};
return (
<div className={classNames('Input_container', containerClassName, {disabled})}>
<fieldset
@ -173,21 +212,7 @@ const Input = React.forwardRef((
<div className={classNames('Input_wrapper', wrapperClassName)}>
{inputPrefix}
{textPrefix && <span>{textPrefix}</span>}
<input
ref={ref}
id={`input_${name || ''}`}
className={classNames('Input form-control', inputSize, inputClassName, {Input__focus: showLegend})}
value={value}
placeholder={focused ? (label && placeholder) || label : label || placeholder}
aria-label={label || placeholder}
name={name}
disabled={disabled}
{...otherProps}
maxLength={limit ? undefined : maxLength}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
onChange={handleOnChange}
/>
{generateInput()}
{limitExceeded > 0 && (
<span className='Input_limit-exceeded'>
{'-'}{limitExceeded}

View File

@ -24,8 +24,8 @@ type URLInputProps = {
shortenLength?: number;
error?: string;
className?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
onBlur?: (event: React.ChangeEvent<HTMLInputElement>) => void;
onChange?: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur?: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
};
function UrlInput({
@ -55,7 +55,7 @@ function UrlInput({
const isShortenedURL = shortenLength && fullURL.length > shortenLength;
const hasError = Boolean(error);
const handleOnInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleOnInputChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
event.preventDefault();
if (onChange) {
@ -63,7 +63,7 @@ function UrlInput({
}
};
const handleOnInputBlur = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleOnInputBlur = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
event.preventDefault();
setEditing(hasError);

View File

@ -9,11 +9,11 @@
padding: 0;
margin: 0 0 8px;
color: var(--center-channel-color);
font-family: 'Open Sans', sans-serif;
font-size: 14px;
font-family: 'Metropolis', sans-serif;
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 20px;
line-height: 24px;
}
&__description {
@ -29,6 +29,24 @@
line-height: 16px;
}
&__error {
display: flex;
align-items: center;
padding: 0;
margin: 12px 0 0 0;
color: var(--error-text);
font-family: 'Open Sans', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
svg {
flex-shrink: 0;
margin-right: 6px;
}
}
&__content {
display: flex;
flex-direction: column;
@ -45,9 +63,12 @@
align-items: center;
cursor: pointer;
font-size: 14px;
font-weight: 400;
gap: 8px;
line-height: 20px;
span {
font-weight: 400;
}
}
&__fieldset-radio {

View File

@ -1,42 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import type {PrimitiveType, FormatXMLElementFn} from 'intl-messageformat';
import React from 'react';
import type {MessageDescriptor} from 'react-intl';
import {useIntl} from 'react-intl';
import {AlertCircleOutlineIcon} from '@mattermost/compass-icons/components';
import './base_setting_item.scss';
type ExtendedMessageDescriptor = MessageDescriptor & {
values?: Record<string, PrimitiveType | FormatXMLElementFn<string, string>>;
};
export type BaseSettingItemProps = {
title?: MessageDescriptor;
description?: MessageDescriptor;
title?: ExtendedMessageDescriptor;
description?: ExtendedMessageDescriptor;
error?: ExtendedMessageDescriptor;
};
type Props = BaseSettingItemProps & {
content: JSX.Element;
className?: string;
descriptionAboveContent?: boolean;
}
function BaseSettingItem({title, description, content}: Props): JSX.Element {
function BaseSettingItem({title, description, content, className, error, descriptionAboveContent = false}: Props): JSX.Element {
const {formatMessage} = useIntl();
const Title = title && (
<h4 className='mm-modal-generic-section-item__title'>
{formatMessage({id: title.id, defaultMessage: title.defaultMessage})}
<h4
data-testid='mm-modal-generic-section-item__title'
className='mm-modal-generic-section-item__title'
>
{formatMessage({id: title.id, defaultMessage: title.defaultMessage}, title.values)}
</h4>
);
const Description = description && (
<p className='mm-modal-generic-section-item__description'>
{formatMessage({id: description.id, defaultMessage: description.defaultMessage})}
<p
data-testid='mm-modal-generic-section-item__description'
className='mm-modal-generic-section-item__description'
>
{formatMessage({id: description.id, defaultMessage: description.defaultMessage}, description.values)}
</p>
);
const Error = error && (
<div
data-testid='mm-modal-generic-section-item__error'
className='mm-modal-generic-section-item__error'
>
<AlertCircleOutlineIcon/>
{formatMessage({id: error.id, defaultMessage: error.defaultMessage}, error.values)}
</div>
);
const getClassName = classNames('mm-modal-generic-section-item', className);
return (
<div className='mm-modal-generic-section-item'>
<div className={getClassName}>
{Title}
<div className='mm-modal-generic-section-item__content'>
{descriptionAboveContent ? Description : undefined}
<div
data-testid='mm-modal-generic-section-item__content'
className='mm-modal-generic-section-item__content'
>
{content}
</div>
{Description}
{descriptionAboveContent ? undefined : Description}
{Error}
</div>
);
}

View File

@ -18,6 +18,8 @@ type Props = BaseSettingItemProps & {
inputFieldData: FieldsetCheckbox;
inputFieldValue: boolean;
handleChange: (e: boolean) => void;
className?: string;
descriptionAboveContent?: boolean;
}
function CheckboxSettingItem({
title,
@ -25,6 +27,8 @@ function CheckboxSettingItem({
inputFieldData,
inputFieldValue,
handleChange,
className,
descriptionAboveContent = false,
}: Props): JSX.Element {
const content = (
<fieldset
@ -53,6 +57,8 @@ function CheckboxSettingItem({
content={content}
title={title}
description={description}
className={className}
descriptionAboveContent={descriptionAboveContent}
/>
);
}

View File

@ -1,7 +1,6 @@
.mm-modal-generic-section {
display: flex;
flex-direction: column;
gap: 16px;
&__info-ctr {
display: flex;

View File

@ -8,7 +8,7 @@ import {useIntl} from 'react-intl';
import './modal_section.scss';
type Props = {
title: MessageDescriptor;
title?: MessageDescriptor;
description?: MessageDescriptor;
content: JSX.Element;
titleSuffix?: JSX.Element;
@ -21,11 +21,11 @@ function ModalSection({
titleSuffix,
}: Props): JSX.Element {
const {formatMessage} = useIntl();
const titleContent = (
const titleContent = title ? (
<h4 className='mm-modal-generic-section__title'>
{formatMessage({id: title.id, defaultMessage: title.defaultMessage})}
</h4>
);
) : undefined;
const descriptionContent = description && (
<p className='mm-modal-generic-section__description'>
@ -43,12 +43,21 @@ function ModalSection({
return titleContent;
}
const titleDescriptionSection = () => {
if (title || description) {
return (
<div className='mm-modal-generic-section__title-description-ctr'>
{titleRow()}
{descriptionContent}
</div>
);
}
return null;
};
return (
<section className='mm-modal-generic-section'>
<div className='mm-modal-generic-section__info-ctr'>
{titleRow()}
{descriptionContent}
</div>
{titleDescriptionSection()}
<div className='mm-modal-generic-section__content'>
{content}
</div>

View File

@ -1,27 +1,72 @@
.mm-save-changes-panel {
position: absolute;
position: fixed;
z-index: 1000;
right: 20px;
bottom: 20px;
display: flex;
width: calc(100% - 232px - 40px);
width: calc(63%);
align-items: center;
padding: 18px;
background-color: var(--dnd-indicator);
padding: 12px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
animation-duration: 300ms;
animation-fill-mode: forwards;
animation-name: entry-animation;
animation-timing-function: ease;
background: var(--center-channel-bg);
border-radius: 4px;
box-shadow: var(--elevation-3);
font-family: 'Open Sans', sans-serif;
transition: all 300ms ease;
@keyframes entry-animation {
from {
bottom: 0;
}
to {
bottom: 20px;
}
}
@media screen and (max-width: 768px) {
width: calc(95%);
}
&.error {
border-color: var(--dnd-indicator);
background: var(--dnd-indicator);
}
&.saved {
border-color: var(--online-indicator);
background: var(--online-indicator);
}
&__message {
display: flex;
align-items: center;
margin: 0;
color: var(--sidebar-text);
color: var(--center-channel-color);
font-family: inherit;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px;
span {
margin-left: 8px;
}
svg {
color: rgba(var(--center-channel-color-rgb), 0.56);
}
&.error,
&.saved {
color: var(--button-color);
svg {
color: var(--button-color);
}
}
}
&__btn-ctr {
@ -29,34 +74,21 @@
flex: 1 1 auto;
justify-content: flex-end;
gap: 8px;
#panelCloseButton {
color: rgba(var(--button-color-rgb), 0.64);
font-size: 16px;
}
}
&__cancel-btn {
display: flex;
width: 64px;
min-width: 64px;
height: 32px;
align-items: center;
padding: 10px 16px;
border: none;
background: rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: var(--sidebar-text);
font-family: inherit;
font-size: 12px;
font-weight: 600;
gap: 10px;
line-height: 10px;
}
&__save-btn {
display: flex;
width: 59px;
height: 32px;
flex-direction: column;
align-items: center;
padding: 10px 16px;
border: none;
background: var(--sidebar-text);
background: rgba(var(--denim-button-bg-rgb), 0.08);
border-radius: 4px;
color: var(--denim-button-bg);
font-family: inherit;
@ -64,5 +96,33 @@
font-weight: 600;
gap: 10px;
line-height: 10px;
&.error {
background: rgba(var(--button-color-rgb), 0.12);
color: var(--button-color);
}
}
&__save-btn {
display: flex;
min-width: 59px;
height: 32px;
flex-direction: column;
align-items: center;
padding: 10px 16px;
border: none;
background: var(--denim-button-bg);
border-radius: 4px;
color: var(--button-color);
font-family: inherit;
font-size: 12px;
font-weight: 600;
gap: 10px;
line-height: 10px;
&.error {
background-color: var(--button-color);
color: var(--denim-button-bg);
}
}
}

View File

@ -1,32 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import classNames from 'classnames';
import React, {useEffect} from 'react';
import {FormattedMessage} from 'react-intl';
import './save_changes_panel.scss';
import {AlertCircleOutlineIcon} from '@mattermost/compass-icons/components';
import './save_changes_panel.scss';
export type SaveChangesPanelState = 'editing' | 'saved' | 'error' | undefined;
type Props = {
handleSubmit: () => void;
handleCancel: () => void;
handleClose: () => void;
tabChangeError?: boolean;
state: SaveChangesPanelState;
}
function SaveChangesPanel({handleSubmit, handleCancel}: Props) {
return (
<div className='mm-save-changes-panel'>
<p className='mm-save-changes-panel__message'>
<AlertCircleOutlineIcon
size={18}
color={'currentcolor'}
/>
function SaveChangesPanel({handleSubmit, handleCancel, handleClose, tabChangeError = false, state = 'editing'}: Props) {
const panelClassName = classNames('mm-save-changes-panel', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
const messageClassName = classNames('mm-save-changes-panel__message', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
const cancelButtonClassName = classNames('mm-save-changes-panel__cancel-btn', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
const saveButtonClassName = classNames('mm-save-changes-panel__save-btn', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (state === 'saved') {
timeoutId = setTimeout(() => {
handleClose();
}, 1200);
}
return () => clearTimeout(timeoutId);
}, [handleClose, state]);
const generateMessage = () => {
if (tabChangeError || state === 'editing') {
return (
<FormattedMessage
id='saveChangesPanel.message'
defaultMessage='You have unsaved changes'
/>
</p>
);
}
if (state === 'error') {
return (
<FormattedMessage
id='saveChangesPanel.error'
defaultMessage='There was an error saving your settings'
/>
);
}
return (
<FormattedMessage
id='saveChangesPanel.saved'
defaultMessage='Settings saved'
/>
);
};
const generateControlButtons = () => {
if (state === 'saved') {
return (
<div className='mm-save-changes-panel__btn-ctr'>
<button
id='panelCloseButton'
data-testid='panelCloseButton'
type='button'
className='btn btn-icon btn-sm'
onClick={handleClose}
>
<i
className='icon icon-close'
/>
</button>
</div>
);
}
return (
<div className='mm-save-changes-panel__btn-ctr'>
<button
className='mm-save-changes-panel__cancel-btn'
data-testid='mm-save-changes-panel__cancel-btn'
className={cancelButtonClassName}
onClick={handleCancel}
>
<FormattedMessage
@ -35,15 +94,35 @@ function SaveChangesPanel({handleSubmit, handleCancel}: Props) {
/>
</button>
<button
className='mm-save-changes-panel__save-btn'
data-testid='mm-save-changes-panel__save-btn'
className={saveButtonClassName}
onClick={handleSubmit}
>
<FormattedMessage
id='saveChangesPanel.save'
defaultMessage='Save'
/>
{state === 'error' ?
<FormattedMessage
id='saveChangesPanel.tryAgain'
defaultMessage='Try again'
/> :
<FormattedMessage
id='saveChangesPanel.save'
defaultMessage='Save'
/>
}
</button>
</div>
);
};
return (
<div className={panelClassName}>
<p className={messageClassName}>
<AlertCircleOutlineIcon
size={18}
color={'currentcolor'}
/>
{generateMessage()}
</p>
{generateControlButtons()}
</div>
);
}

View File

@ -3716,34 +3716,26 @@
"general_button.esc": "Esc",
"general_tab.allowedDomains": "Allow only users with a specific email domain to join this team",
"general_tab.allowedDomains.ariaLabel": "Allowed Domains",
"general_tab.allowedDomainsEdit": "Click 'Edit' to add an email domain whitelist.",
"general_tab.AllowedDomainsExample": "corp.mattermost.com, mattermost.com",
"general_tab.AllowedDomainsInfo": "Users can only join the team if their email matches a specific domain (e.g. \"mattermost.com\") or list of comma-separated domains (e.g. \"corp.mattermost.com, mattermost.com\").",
"general_tab.chooseDescription": "Please choose a new description for your team",
"general_tab.codeDesc": "Click 'Edit' to regenerate Invite Code.",
"general_tab.AllowedDomainsInfo": "When enabled, users can only join the team if their email matches a specific domain (e.g. \"mattermost.org\")",
"general_tab.AllowedDomainsTip": "Seperate multiple domains with a space, comma, tab or enter.",
"general_tab.AllowedDomainsTitle": "Users with a specific email domain",
"general_tab.codeLongDesc": "The Invite Code is part of the unique team invitation link which is sent to members youre inviting to this team. Regenerating the code creates a new invitation link and invalidates the previous link.",
"general_tab.codeTitle": "Invite Code",
"general_tab.emptyDescription": "Click 'Edit' to add a team description.",
"general_tab.getTeamInviteLink": "Get Team Invite Link",
"general_tab.no": "No",
"general_tab.openInviteDesc": "When allowed, a link to this team will be included on the landing page allowing anyone with an account to join this team. Changing from \"Yes\" to \"No\" will regenerate the invitation code, create a new invitation link and invalidate the previous link.",
"general_tab.openInviteDesc": "When allowed, a link to this team will be included on the landing page allowing anyone with an account to join this team. Changing from 'Yes' to 'No' will regenerate the invitation code, create a new invitation link and invalidate the previous link.",
"general_tab.openInviteText": "Users on this server",
"general_tab.openInviteTitle": "Allow any user with an account on this server to join this team",
"general_tab.regenerate": "Regenerate",
"general_tab.required": "This field is required",
"general_tab.teamDescription": "Team Description",
"general_tab.teamDescription": "Description",
"general_tab.teamDescriptionInfo": "Team description provides additional information to help users select the right team. Maximum of 50 characters.",
"general_tab.teamIcon": "Team Icon",
"general_tab.teamIconEditHint": "Click 'Edit' to upload an image.",
"general_tab.teamIconEditHintMobile": "Click to upload an image",
"general_tab.teamIconError": "An error occurred while selecting the image.",
"general_tab.teamIconInvalidFileType": "Only BMP, JPG or PNG images may be used for team icons",
"general_tab.teamIconLastUpdated": "Image last updated {date}",
"general_tab.teamIconTooLarge": "Unable to upload team icon. File is too large.",
"general_tab.teamInfo": "Team info",
"general_tab.teamName": "Team Name",
"general_tab.teamNameInfo": "Set the name of the team as it appears on your sign-in screen and at the top of the left-hand sidebar.",
"general_tab.teamNameInfo": "This name will appear on your sign-in screen and at the top of the left sidebar.",
"general_tab.teamNameRestrictions": "Team Name must be {min} or more characters up to a maximum of {max}. You can add a longer team description.",
"general_tab.title": "General Settings",
"general_tab.yes": "Yes",
"generic_btn.cancel": "Cancel",
"generic_btn.save": "Save",
"generic_icons.add": "Add Icon",
@ -3796,7 +3788,6 @@
"generic_icons.reload": "Reload Icon",
"generic_icons.reply": "Reply Icon",
"generic_icons.search": "Search Icon",
"generic_icons.settings": "Settings Icon",
"generic_icons.success": "Success Icon",
"generic_icons.upgradeBadge": "Upgrade badge",
"generic_icons.upload": "Upload Icon",
@ -4800,8 +4791,11 @@
"save_button.save": "Save",
"save_button.saving": "Saving",
"saveChangesPanel.cancel": "Undo",
"saveChangesPanel.error": "There was an error saving your settings",
"saveChangesPanel.message": "You have unsaved changes",
"saveChangesPanel.save": "Save",
"saveChangesPanel.saved": "Settings saved",
"saveChangesPanel.tryAgain": "Try again",
"search_bar.files_tab": "Files",
"search_bar.messages_tab": "Messages",
"search_bar.search": "Search",
@ -4904,11 +4898,12 @@
"setting_picture.cancel": "Cancel",
"setting_picture.help.profile": "Upload a picture in BMP, JPG, JPEG, or PNG format. Maximum file size: {max}",
"setting_picture.help.profile.example": "Upload a picture in BMP, JPG or PNG format. Maximum file size: {max}",
"setting_picture.help.team": "Upload a team icon in BMP, JPG or PNG format.\nSquare images with a solid background color are recommended.",
"setting_picture.remove": "Remove This Icon",
"setting_picture.remove_image": "Remove image",
"setting_picture.remove_profile_picture": "Remove Profile Picture",
"setting_picture.save": "Save",
"setting_picture.select": "Select",
"setting_picture.title": "Team Icon",
"setting_picture.uploading": "Uploading...",
"shared_channel_indicator.tooltip": "Shared with trusted organizations",
"shared_user_indicator.aria_label": "shared user indicator",
@ -5258,11 +5253,11 @@
"team_members_dropdown.teamAdmin": "Team Admin",
"team_members_dropdown.teamAdmins": "Team Admins",
"team_members_dropdown.teamMembers": "Team Members",
"team_settings_modal.generalTab": "General",
"team_settings_modal.accessTab": "Access",
"team_settings_modal.infoTab": "Info",
"team_settings_modal.title": "Team Settings",
"team_settings.openInviteDescription.ariaLabel": "Invite Code",
"team_settings.openInviteDescription.groupConstrained": "No, members of this team are added and removed by linked groups. <link>Learn More</link>",
"team_settings.openInviteSetting.groupConstrained": "No, members of this team are added and removed by linked groups.",
"team_settings.openInviteDescription.error": "There was an error generating the invite code, please try again",
"team_settings.openInviteDescription.groupConstrained": "Members of this team are added and removed by linked groups. <link>Learn More</link>",
"team_sidebar.join": "Other teams you can join",
"team.button.ariaLabel": "{teamName} team",
"team.button.mentions.ariaLabel": "{teamName} team, {mentionCount} mentions",

View File

@ -44,6 +44,8 @@
}
.settings-table {
min-height: 475px;
.settings-links {
width: 232px;
padding: 16px;

View File

@ -234,6 +234,7 @@
top: 0;
display: flex;
width: 100%;
background-color: var(--center-channel-bg);
.modal-title {
width: 100%;
@ -295,6 +296,8 @@
padding: 0;
.user-settings {
overflow: auto;
max-height: 100vh;
padding: 100px 20px 30px;
}
}

View File

@ -212,7 +212,7 @@
.settings-content {
overflow: auto;
flex: 1;
padding: 0 20px 30px;
padding: 20px 30px;
.modal-header {
display: none;
@ -492,6 +492,11 @@
}
}
.nav {
position: fixed;
margin: 0;
}
.nav-pills {
> li {
margin: 0;
@ -570,6 +575,7 @@
}
.tab-header {
margin-top: 0;
margin-bottom: 1em;
font-weight: 600;
}

View File

@ -1046,7 +1046,7 @@ export function defaultImageURLForUser(userId: UserProfile['id']) {
}
// in contrast to Client4.getTeamIconUrl, for ui logic this function returns null if last_team_icon_update is unset
export function imageURLForTeam(team: Team & {last_team_icon_update?: number}) {
export function imageURLForTeam(team: Team) {
return team.last_team_icon_update ? Client4.getTeamIconUrl(team.id, team.last_team_icon_update) : null;
}

View File

@ -39,6 +39,7 @@ export type Team = {
scheme_id: string;
group_constrained: boolean;
policy_id?: string | null;
last_team_icon_update?: number;
};
export type TeamsState = {