mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
Merge master into debug/binary-timeout
This commit is contained in:
commit
d552098d62
27 changed files with 1109 additions and 471 deletions
51
.github/codecov.yml
vendored
51
.github/codecov.yml
vendored
|
|
@ -1,17 +1,42 @@
|
|||
comment:
|
||||
layout: "condensed_header, condensed_files, condensed_footer"
|
||||
behavior: default
|
||||
require_changes: "uncovered_patch" # only post comment if the patch has uncovered lines
|
||||
hide_project_coverage: true # only show coverage on the git diff
|
||||
codecov:
|
||||
require_ci_to_pass: false
|
||||
# Wait for all coverage uploads (4 server shards + 1 webapp) before
|
||||
# computing status. Without this, Codecov may report partial coverage
|
||||
# from the first shard to finish, showing a misleading drop on the PR.
|
||||
notify:
|
||||
after_n_builds: 5
|
||||
|
||||
coverage:
|
||||
status:
|
||||
changes: false
|
||||
patch: false
|
||||
project:
|
||||
default:
|
||||
threshold: 1.0
|
||||
codecov:
|
||||
notify:
|
||||
after_n_builds: 2 # Server and webapp at this point
|
||||
ignore:
|
||||
- ^store/storetest.*
|
||||
target: auto
|
||||
threshold: 1%
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
target: 50%
|
||||
informational: true
|
||||
|
||||
# Exclude generated code, mocks, and test infrastructure from reporting.
|
||||
# Go compiles these into the test binary, so they appear in cover.out,
|
||||
# but they aren't production code and inflate the denominator.
|
||||
ignore:
|
||||
- "server/**/retrylayer/**"
|
||||
- "server/**/timerlayer/**"
|
||||
- "server/**/*_serial_gen.go"
|
||||
- "server/**/mocks/**"
|
||||
- "server/**/storetest/**"
|
||||
- "server/**/plugintest/**"
|
||||
- "server/**/searchtest/**"
|
||||
|
||||
flags:
|
||||
server:
|
||||
after_n_builds: 4 # 4 server test shards
|
||||
webapp:
|
||||
after_n_builds: 1 # 1 merged webapp upload
|
||||
|
||||
comment:
|
||||
layout: "condensed_header,diff,flags"
|
||||
behavior: default
|
||||
require_changes: true
|
||||
|
|
|
|||
18
.github/workflows/server-ci.yml
vendored
18
.github/workflows/server-ci.yml
vendored
|
|
@ -208,6 +208,7 @@ jobs:
|
|||
logsartifact: postgres-binary-server-test-logs
|
||||
go-version: ${{ needs.go.outputs.version }}
|
||||
fips-enabled: false
|
||||
fullyparallel: false
|
||||
# -- Sharded into 4 parallel runners for ~88% wall-time improvement --
|
||||
test-postgres-normal:
|
||||
name: Postgres (shard ${{ matrix.shard }})
|
||||
|
|
@ -270,22 +271,27 @@ jobs:
|
|||
artifact-name: postgres-server-fips-test-logs
|
||||
|
||||
test-coverage:
|
||||
name: Generate Test Coverage
|
||||
# Disabled: Running out of memory and causing spurious failures.
|
||||
# Old condition: ${{ github.event_name != 'pull_request' || !startsWith(github.event.pull_request.base.ref, 'release-') }}
|
||||
if: false
|
||||
name: "Coverage (shard ${{ matrix.shard }})"
|
||||
if: ${{ github.event_name != 'pull_request' || !startsWith(github.event.pull_request.base.ref, 'release-') }}
|
||||
needs: go
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [0, 1, 2, 3]
|
||||
uses: ./.github/workflows/server-test-template.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
name: Generate Test Coverage
|
||||
name: "Coverage (shard ${{ matrix.shard }})"
|
||||
datasource: postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10
|
||||
drivername: postgres
|
||||
logsartifact: coverage-server-test-logs
|
||||
logsartifact: "coverage-server-test-logs-shard-${{ matrix.shard }}"
|
||||
fullyparallel: true
|
||||
allow-failure: true
|
||||
enablecoverage: true
|
||||
go-version: ${{ needs.go.outputs.version }}
|
||||
fips-enabled: false
|
||||
shard-index: ${{ matrix.shard }}
|
||||
shard-total: 4
|
||||
test-mmctl:
|
||||
name: Run mmctl tests
|
||||
needs: go
|
||||
|
|
|
|||
1
.github/workflows/server-test-template.yml
vendored
1
.github/workflows/server-test-template.yml
vendored
|
|
@ -222,6 +222,7 @@ jobs:
|
|||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
disable_search: true
|
||||
files: server/cover.out
|
||||
flags: server
|
||||
|
||||
- name: Stop docker compose
|
||||
run: |
|
||||
|
|
|
|||
1
.github/workflows/webapp-ci.yml
vendored
1
.github/workflows/webapp-ci.yml
vendored
|
|
@ -223,6 +223,7 @@ jobs:
|
|||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
disable_search: true
|
||||
files: ./webapp/channels/coverage/merged/lcov.info
|
||||
flags: webapp
|
||||
|
||||
build:
|
||||
needs: check-lint
|
||||
|
|
|
|||
|
|
@ -209,12 +209,8 @@ describe('Authentication', () => {
|
|||
// # Go to front page
|
||||
cy.visit('/login');
|
||||
|
||||
// * Assert that create account button is visible
|
||||
cy.findByText('Don\'t have an account?', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').click();
|
||||
|
||||
// * Verify redirection to access problem page since account creation is disabled
|
||||
cy.url().should('include', '/access_problem');
|
||||
cy.findByText('Contact your workspace admin');
|
||||
// * Assert that create account button is not visible
|
||||
cy.findByText('Don\'t have an account?', {timeout: TIMEOUTS.ONE_MIN}).should('not.exist');
|
||||
|
||||
// # Go to sign up with email page
|
||||
cy.visit('/signup_user_complete');
|
||||
|
|
|
|||
|
|
@ -36,8 +36,7 @@ describe('Login page with close server', () => {
|
|||
// Restore backed up settings
|
||||
cy.apiAdminLogin().apiUpdateConfig(oldSettings);
|
||||
});
|
||||
it('MM-47222 Should verify access problem page can be reached', () => {
|
||||
cy.findByText('Don\'t have an account?').should('be.visible').click();
|
||||
cy.findByText('Contact your workspace admin').should('be.visible');
|
||||
it('MM-47222 Should verify signup link not visible', () => {
|
||||
cy.findByText('Don\'t have an account?').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,10 +49,28 @@ describe('Customization', () => {
|
|||
// # Save setting
|
||||
saveSetting();
|
||||
|
||||
// # Verify that after page reload image exist
|
||||
cy.reload();
|
||||
cy.findByTestId('CustomBrandImage').should('be.visible').within(() => {
|
||||
// * Verify that after page reload image exist
|
||||
cy.get('img').should('have.attr', 'src').and('include', '/api/v4/brand/image?t=');
|
||||
|
||||
// * Verify that there's an option to delete the image.
|
||||
cy.findByTestId('remove-image__btn').should('be.visible');
|
||||
|
||||
// # delete the image
|
||||
cy.findByTestId('remove-image__btn').click();
|
||||
});
|
||||
|
||||
// # Save setting
|
||||
saveSetting();
|
||||
|
||||
cy.reload();
|
||||
cy.findByTestId('CustomBrandImage').should('be.visible').within(() => {
|
||||
// * Verify that after page reload, the image doesn't exist.
|
||||
cy.findByAltText('brand image').should('not.exist');
|
||||
|
||||
// * Verify there's no option to delete the image.
|
||||
cy.findByTestId('remove-image__btn').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.8.0
|
|||
PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.12.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-agents-v1.7.2
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.2
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.4
|
||||
PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-mscalendar-v1.6.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.4.1
|
||||
|
|
|
|||
|
|
@ -657,6 +657,10 @@ func getTeamMember(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionManageTeamRoles) {
|
||||
team.SanitizeRoleData(c.AppContext.Session().UserId)
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(team); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
|
|
@ -695,6 +699,13 @@ func getTeamMembers(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
currentUserId := c.AppContext.Session().UserId
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionManageTeamRoles) {
|
||||
for _, m := range members {
|
||||
m.SanitizeRoleData(currentUserId)
|
||||
}
|
||||
}
|
||||
|
||||
js, err := json.Marshal(members)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("getTeamMembers", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
|
|
@ -734,6 +745,13 @@ func getTeamMembersForUser(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
currentUserId := c.AppContext.Session().UserId
|
||||
for _, m := range members {
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), m.TeamId, model.PermissionManageTeamRoles) {
|
||||
m.SanitizeRoleData(currentUserId)
|
||||
}
|
||||
}
|
||||
|
||||
js, err := json.Marshal(members)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("getTeamMembersForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
|
|
@ -777,6 +795,13 @@ func getTeamMembersByIds(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
currentUserId := c.AppContext.Session().UserId
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionManageTeamRoles) {
|
||||
for _, m := range members {
|
||||
m.SanitizeRoleData(currentUserId)
|
||||
}
|
||||
}
|
||||
|
||||
js, err := json.Marshal(members)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("getTeamMembersByIds", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
|
|
@ -883,6 +908,10 @@ func addTeamMember(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
auditRec.AddEventObjectType("team_member") // TODO verify this is the final state. should it be the team instead?
|
||||
auditRec.Success()
|
||||
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionManageTeamRoles) {
|
||||
tm.SanitizeRoleData(c.AppContext.Session().UserId)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(tm); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
|
|
@ -1037,6 +1066,15 @@ func addTeamMembers(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
currentUserId := c.AppContext.Session().UserId
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionManageTeamRoles) {
|
||||
for _, m := range membersWithErrors {
|
||||
if m.Member != nil {
|
||||
m.Member.SanitizeRoleData(currentUserId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
js []byte
|
||||
err error
|
||||
|
|
|
|||
|
|
@ -4609,3 +4609,247 @@ func TestInvalidateAllEmailInvites(t *testing.T) {
|
|||
CheckOKStatus(t, res)
|
||||
})
|
||||
}
|
||||
|
||||
func setupTeamWithAdminAndMember(t *testing.T, th *TestHelper) *model.Client4 {
|
||||
t.Helper()
|
||||
th.UpdateUserToTeamAdmin(t, th.BasicUser2, th.BasicTeam)
|
||||
require.Nil(t, th.App.Srv().InvalidateAllCaches())
|
||||
teamAdminClient := th.CreateClient()
|
||||
_, _, err := teamAdminClient.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
|
||||
require.NoError(t, err)
|
||||
return teamAdminClient
|
||||
}
|
||||
|
||||
func assertRoleDataSanitized(t *testing.T, m *model.TeamMember) {
|
||||
t.Helper()
|
||||
assert.Empty(t, m.Roles)
|
||||
assert.Empty(t, m.ExplicitRoles)
|
||||
assert.False(t, m.SchemeAdmin)
|
||||
assert.False(t, m.SchemeGuest)
|
||||
assert.False(t, m.SchemeUser)
|
||||
assert.Equal(t, int64(-1), m.DeleteAt)
|
||||
}
|
||||
|
||||
func TestGetTeamMembersRoleDataSanitization(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
teamAdminClient := setupTeamWithAdminAndMember(t, th)
|
||||
|
||||
t.Run("non-admin cannot see role data of others", func(t *testing.T) {
|
||||
members, _, err := th.Client.GetTeamMembers(context.Background(), th.BasicTeam.Id, 0, 100, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, m := range members {
|
||||
if m.UserId != th.BasicUser.Id {
|
||||
assertRoleDataSanitized(t, m)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-admin sees own role data", func(t *testing.T) {
|
||||
members, _, err := th.Client.GetTeamMembers(context.Background(), th.BasicTeam.Id, 0, 100, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, m := range members {
|
||||
if m.UserId == th.BasicUser.Id {
|
||||
assert.True(t, m.SchemeUser)
|
||||
return
|
||||
}
|
||||
}
|
||||
require.Fail(t, "current user not found in members")
|
||||
})
|
||||
|
||||
t.Run("team admin sees full role data for other user", func(t *testing.T) {
|
||||
members, _, err := teamAdminClient.GetTeamMembers(context.Background(), th.BasicTeam.Id, 0, 100, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, m := range members {
|
||||
if m.UserId == th.BasicUser.Id {
|
||||
assert.True(t, m.SchemeUser)
|
||||
return
|
||||
}
|
||||
}
|
||||
require.Fail(t, "target user not found in members")
|
||||
})
|
||||
|
||||
t.Run("system admin sees full role data", func(t *testing.T) {
|
||||
members, _, err := th.SystemAdminClient.GetTeamMembers(context.Background(), th.BasicTeam.Id, 0, 100, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, m := range members {
|
||||
if m.UserId == th.BasicUser2.Id {
|
||||
assert.True(t, m.SchemeAdmin)
|
||||
return
|
||||
}
|
||||
}
|
||||
require.Fail(t, "team admin not found in members")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTeamMemberRoleDataSanitization(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
teamAdminClient := setupTeamWithAdminAndMember(t, th)
|
||||
|
||||
t.Run("non-admin cannot see role data of others", func(t *testing.T) {
|
||||
member, _, err := th.Client.GetTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser2.Id, "")
|
||||
require.NoError(t, err)
|
||||
assertRoleDataSanitized(t, member)
|
||||
})
|
||||
|
||||
t.Run("non-admin sees own role data", func(t *testing.T) {
|
||||
member, _, err := th.Client.GetTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, member.SchemeUser)
|
||||
})
|
||||
|
||||
t.Run("team admin sees full role data for other user", func(t *testing.T) {
|
||||
member, _, err := teamAdminClient.GetTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, member.SchemeUser)
|
||||
})
|
||||
|
||||
t.Run("system admin sees full role data", func(t *testing.T) {
|
||||
member, _, err := th.SystemAdminClient.GetTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser2.Id, "")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, member.SchemeAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTeamMembersByIdsRoleDataSanitization(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
teamAdminClient := setupTeamWithAdminAndMember(t, th)
|
||||
|
||||
t.Run("non-admin cannot see role data of others", func(t *testing.T) {
|
||||
members, _, err := th.Client.GetTeamMembersByIds(context.Background(), th.BasicTeam.Id, []string{th.BasicUser2.Id})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, members, 1)
|
||||
assertRoleDataSanitized(t, members[0])
|
||||
})
|
||||
|
||||
t.Run("non-admin sees own role data", func(t *testing.T) {
|
||||
members, _, err := th.Client.GetTeamMembersByIds(context.Background(), th.BasicTeam.Id, []string{th.BasicUser.Id})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, members, 1)
|
||||
assert.True(t, members[0].SchemeUser)
|
||||
})
|
||||
|
||||
t.Run("team admin sees full role data for other user", func(t *testing.T) {
|
||||
members, _, err := teamAdminClient.GetTeamMembersByIds(context.Background(), th.BasicTeam.Id, []string{th.BasicUser.Id})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, members, 1)
|
||||
assert.True(t, members[0].SchemeUser)
|
||||
})
|
||||
|
||||
t.Run("system admin sees full role data", func(t *testing.T) {
|
||||
members, _, err := th.SystemAdminClient.GetTeamMembersByIds(context.Background(), th.BasicTeam.Id, []string{th.BasicUser2.Id})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, members, 1)
|
||||
assert.True(t, members[0].SchemeAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddTeamMemberRoleDataSanitization(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
teamAdminClient := setupTeamWithAdminAndMember(t, th)
|
||||
|
||||
t.Run("team admin adding user sees full role data in response", func(t *testing.T) {
|
||||
newUser := th.CreateUser(t)
|
||||
tm, _, err := teamAdminClient.AddTeamMember(context.Background(), th.BasicTeam.Id, newUser.Id)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, tm.SchemeUser)
|
||||
})
|
||||
|
||||
t.Run("non-admin adding user sees sanitized role data in response", func(t *testing.T) {
|
||||
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
|
||||
defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
|
||||
th.AddPermissionToRole(t, model.PermissionAddUserToTeam.Id, model.TeamUserRoleId)
|
||||
|
||||
newUser := th.CreateUser(t)
|
||||
tm, _, err := th.Client.AddTeamMember(context.Background(), th.BasicTeam.Id, newUser.Id)
|
||||
require.NoError(t, err)
|
||||
assertRoleDataSanitized(t, tm)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddTeamMembersRoleDataSanitization(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
teamAdminClient := setupTeamWithAdminAndMember(t, th)
|
||||
|
||||
t.Run("team admin adding users sees full role data in response", func(t *testing.T) {
|
||||
newUser := th.CreateUser(t)
|
||||
members, _, err := teamAdminClient.AddTeamMembers(context.Background(), th.BasicTeam.Id, []string{newUser.Id})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, members, 1)
|
||||
assert.True(t, members[0].SchemeUser)
|
||||
})
|
||||
|
||||
t.Run("non-admin adding users sees sanitized role data in response", func(t *testing.T) {
|
||||
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
|
||||
defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
|
||||
th.AddPermissionToRole(t, model.PermissionAddUserToTeam.Id, model.TeamUserRoleId)
|
||||
|
||||
newUser := th.CreateUser(t)
|
||||
members, _, err := th.Client.AddTeamMembers(context.Background(), th.BasicTeam.Id, []string{newUser.Id})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, members, 1)
|
||||
assertRoleDataSanitized(t, members[0])
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTeamMembersForUserRoleDataSanitization(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
teamAdminClient := setupTeamWithAdminAndMember(t, th)
|
||||
|
||||
t.Run("user sees own role data", func(t *testing.T) {
|
||||
members, _, err := th.Client.GetTeamMembersForUser(context.Background(), th.BasicUser.Id, "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, members)
|
||||
for _, m := range members {
|
||||
assert.True(t, m.SchemeUser)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot see role data of another user", func(t *testing.T) {
|
||||
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
|
||||
defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
|
||||
th.AddPermissionToRole(t, model.PermissionReadOtherUsersTeams.Id, model.SystemUserRoleId)
|
||||
|
||||
members, _, err := th.Client.GetTeamMembersForUser(context.Background(), th.BasicUser2.Id, "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, members)
|
||||
for _, m := range members {
|
||||
assertRoleDataSanitized(t, m)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("team admin sees full role data for other user in managed team", func(t *testing.T) {
|
||||
members, _, err := teamAdminClient.GetTeamMembersForUser(context.Background(), th.BasicUser.Id, "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, members)
|
||||
for _, m := range members {
|
||||
if m.TeamId == th.BasicTeam.Id {
|
||||
assert.True(t, m.SchemeUser)
|
||||
return
|
||||
}
|
||||
}
|
||||
require.Fail(t, "basic team membership not found")
|
||||
})
|
||||
|
||||
t.Run("system admin sees full role data", func(t *testing.T) {
|
||||
members, _, err := th.SystemAdminClient.GetTeamMembersForUser(context.Background(), th.BasicUser2.Id, "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, members)
|
||||
for _, m := range members {
|
||||
if m.TeamId == th.BasicTeam.Id {
|
||||
assert.True(t, m.SchemeAdmin)
|
||||
return
|
||||
}
|
||||
}
|
||||
require.Fail(t, "basic team membership not found")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,3 +142,14 @@ func (o *TeamMember) PreUpdate() {
|
|||
func (o *TeamMember) GetRoles() []string {
|
||||
return strings.Fields(o.Roles)
|
||||
}
|
||||
|
||||
func (o *TeamMember) SanitizeRoleData(currentUserId string) {
|
||||
if o.UserId != currentUserId {
|
||||
o.Roles = ""
|
||||
o.ExplicitRoles = ""
|
||||
o.SchemeAdmin = false
|
||||
o.SchemeGuest = false
|
||||
o.SchemeUser = false
|
||||
o.DeleteAt = -1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,20 @@ fi
|
|||
|
||||
cat gotestsum-*.json > gotestsum.json 2>/dev/null || true
|
||||
|
||||
# ── Merge coverage profiles within this shard (if coverage is enabled) ──
|
||||
# A single shard may run multiple gotestsum invocations (light packages +
|
||||
# heavy package splits), each producing its own cover-N.out. This merges
|
||||
# them into one cover.out per shard. The cross-shard merge (combining all
|
||||
# shards into a single report) is handled by Codecov's after_n_builds.
|
||||
if [[ "${ENABLE_COVERAGE:-false}" == "true" ]] && ls cover-*.out 1>/dev/null 2>&1; then
|
||||
echo "Merging coverage profiles..."
|
||||
{
|
||||
head -1 cover-0.out # "mode: atomic" header
|
||||
tail -q -n +2 cover-*.out # data lines from all files
|
||||
} > cover.out
|
||||
echo "Merged $(ls cover-*.out | wc -l) coverage files into cover.out"
|
||||
fi
|
||||
|
||||
if [[ $FAILURES -gt 0 ]]; then
|
||||
echo "Shard complete: $RUN_IDX gotestsum runs, $FAILURES failed"
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
.header-footer-route .header-footer-route-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.AccessProblem {
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 32px 0 16px 0;
|
||||
color: var(--portal-denim);
|
||||
font-family: 'Metropolis';
|
||||
font-size: 22px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__description {
|
||||
display: flex;
|
||||
max-width: 640px;
|
||||
align-items: center;
|
||||
padding: 0 40px;
|
||||
color: var(--portal-denim);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useHistory} from 'react-router-dom';
|
||||
|
||||
import AccessProblemSVG from 'components/common/svg_images_components/access_problem_svg';
|
||||
import type {CustomizeHeaderType} from 'components/header_footer_route/header_footer_route';
|
||||
|
||||
import './access_problem.scss';
|
||||
|
||||
type AccessProblemProps = {
|
||||
onCustomizeHeader?: CustomizeHeaderType;
|
||||
}
|
||||
|
||||
const AccessProblem = ({
|
||||
onCustomizeHeader,
|
||||
}: AccessProblemProps) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const handleHeaderBackButtonOnClick = useCallback(() => {
|
||||
history.goBack();
|
||||
}, [history]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onCustomizeHeader) {
|
||||
onCustomizeHeader({
|
||||
onBackButtonClick: handleHeaderBackButtonOnClick,
|
||||
});
|
||||
}
|
||||
}, [onCustomizeHeader, handleHeaderBackButtonOnClick]);
|
||||
|
||||
return (
|
||||
<div className='AccessProblem__body'>
|
||||
<AccessProblemSVG
|
||||
width={320}
|
||||
height={190}
|
||||
/>
|
||||
<div className='AccessProblem__title'>
|
||||
{formatMessage({id: 'login.contact_admin.title', defaultMessage: 'Contact your workspace admin'})}
|
||||
</div>
|
||||
<div className='AccessProblem__description'>
|
||||
{formatMessage({id: 'login.contact_admin.detail', defaultMessage: "To access your team's workspace, contact your workspace admin. If you've been invited already, check your email inbox for a Mattermost workspace invite."})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessProblem;
|
||||
|
|
@ -1,30 +1,18 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import nock from 'nock';
|
||||
import React from 'react';
|
||||
|
||||
import {uploadBrandImage, deleteBrandImage} from 'actions/admin_actions.jsx';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
|
||||
|
||||
import BrandImageSetting from './brand_image_setting';
|
||||
|
||||
// Real implementations are async (await dispatch(...)); mocks must return Promises so handleSave can await them.
|
||||
jest.mock('actions/admin_actions.jsx', () => ({
|
||||
...jest.requireActual('actions/admin_actions.jsx'),
|
||||
uploadBrandImage: jest.fn(async () => {}),
|
||||
deleteBrandImage: jest.fn(async () => {}),
|
||||
}));
|
||||
Client4.setUrl('http://localhost:8065');
|
||||
|
||||
describe('components/admin_console/brand_image_setting', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({status: 404} as Response);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
disabled: false,
|
||||
setSaveNeeded: jest.fn(),
|
||||
|
|
@ -32,71 +20,59 @@ describe('components/admin_console/brand_image_setting', () => {
|
|||
unRegisterSaveAction: jest.fn(),
|
||||
};
|
||||
|
||||
test('should have called deleteBrandImage or uploadBrandImage on save depending on component state', async () => {
|
||||
let saveAction: (() => Promise<unknown>) | undefined;
|
||||
const registerSaveAction = jest.fn((fn: () => Promise<unknown>) => {
|
||||
saveAction = fn;
|
||||
});
|
||||
const deleteButtonTestId = 'remove-image__btn';
|
||||
|
||||
const {container, unmount} = renderWithContext(
|
||||
<BrandImageSetting
|
||||
{...baseProps}
|
||||
registerSaveAction={registerSaveAction}
|
||||
/>,
|
||||
);
|
||||
let scope: nock.Scope;
|
||||
|
||||
// Wait for componentDidMount fetch to resolve
|
||||
await waitFor(() => {
|
||||
expect(registerSaveAction).toHaveBeenCalled();
|
||||
});
|
||||
expect(saveAction).toBeDefined();
|
||||
beforeAll(() => {
|
||||
scope = nock(Client4.getBaseRoute()).persist().get('/brand/image').query(true).reply(200);
|
||||
});
|
||||
|
||||
// Simulate selecting a file via the file input to set brandImage
|
||||
const file = new File(['brand_image_file'], 'brand.png', {type: 'image/png'});
|
||||
const fileInput = container.querySelector('input[type="file"]');
|
||||
expect(fileInput).toBeInTheDocument();
|
||||
await userEvent.upload(fileInput as HTMLInputElement, file);
|
||||
afterAll(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
// Now call save - should call uploadBrandImage
|
||||
await saveAction!();
|
||||
expect(deleteBrandImage).toHaveBeenCalledTimes(0);
|
||||
expect(uploadBrandImage).toHaveBeenCalledTimes(1);
|
||||
test('should register and unregister save handler when mounted and unmounted respectively', () => {
|
||||
const {unmount} = renderWithContext(<BrandImageSetting {...baseProps}/>);
|
||||
|
||||
expect(baseProps.registerSaveAction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// To test deleteBrandImage path, unmount then re-mount with fetch returning 200
|
||||
unmount();
|
||||
jest.clearAllMocks();
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({status: 200} as Response);
|
||||
|
||||
let saveAction2: (() => Promise<unknown>) | undefined;
|
||||
const registerSaveAction2 = jest.fn((fn: () => Promise<unknown>) => {
|
||||
saveAction2 = fn;
|
||||
});
|
||||
expect(baseProps.unRegisterSaveAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
renderWithContext(
|
||||
<BrandImageSetting
|
||||
{...baseProps}
|
||||
registerSaveAction={registerSaveAction2}
|
||||
/>,
|
||||
);
|
||||
test('should show delete button if brand image exists', async () => {
|
||||
renderWithContext(<BrandImageSetting {...baseProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerSaveAction2).toHaveBeenCalled();
|
||||
});
|
||||
expect(saveAction2).toBeDefined();
|
||||
await waitFor(() => expect(scope.isDone()).toBe(true));
|
||||
|
||||
expect(screen.getByTestId(deleteButtonTestId)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should hide delete button if the setting is disabled', async () => {
|
||||
const props = {...baseProps, disabled: true};
|
||||
|
||||
renderWithContext(<BrandImageSetting {...props}/>);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId(deleteButtonTestId)).toBe(null));
|
||||
});
|
||||
|
||||
test('should call setSaveNeeded when a brand image is uploaded', async () => {
|
||||
renderWithContext(<BrandImageSetting {...baseProps}/>);
|
||||
|
||||
await userEvent.upload(screen.getByTestId('file__upload-input'), new File(['brand_image_file'], 'brand_image_file.png', {type: 'image/png'}));
|
||||
|
||||
expect(baseProps.setSaveNeeded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should call setSaveNeeded when the delete button is pressed', async () => {
|
||||
renderWithContext(<BrandImageSetting {...baseProps}/>);
|
||||
|
||||
const deleteButton = await screen.findByTestId(deleteButtonTestId);
|
||||
|
||||
// Wait for the brand image to be detected and delete button to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('×')).toBeInTheDocument();
|
||||
});
|
||||
const deleteButton = screen.getByText('×').closest('button')!;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No brand image uploaded')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await saveAction2!();
|
||||
expect(deleteBrandImage).toHaveBeenCalledTimes(1);
|
||||
expect(uploadBrandImage).toHaveBeenCalledTimes(0);
|
||||
expect(baseProps.setSaveNeeded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
|
@ -9,6 +9,7 @@ import {Client4} from 'mattermost-redux/client';
|
|||
import {uploadBrandImage, deleteBrandImage} from 'actions/admin_actions.jsx';
|
||||
|
||||
import SettingSet from 'components/admin_console/setting_set';
|
||||
import useDidUpdate from 'components/common/hooks/useDidUpdate';
|
||||
import FormError from 'components/form_error';
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
|
|
@ -44,242 +45,224 @@ type Props = {
|
|||
unRegisterSaveAction: (saveAction: () => Promise<unknown>) => void;
|
||||
};
|
||||
|
||||
type State = {
|
||||
deleteBrandImage: boolean;
|
||||
brandImage?: Blob;
|
||||
brandImageExists: boolean;
|
||||
brandImageTimestamp: number;
|
||||
error: string;
|
||||
};
|
||||
const BrandImageSetting = ({
|
||||
id,
|
||||
disabled,
|
||||
setSaveNeeded,
|
||||
registerSaveAction,
|
||||
unRegisterSaveAction,
|
||||
}: Props) => {
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
export default class BrandImageSetting extends React.PureComponent<Props, State> {
|
||||
private imageRef: React.RefObject<HTMLImageElement>;
|
||||
private fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
const [brandImage, setBrandImage] = useState<Blob | undefined>();
|
||||
const [shouldDeleteBrandImage, setShouldDeleteBrandImage] = useState(false);
|
||||
const [brandImageExists, setBrandImageExists] = useState(false);
|
||||
const [brandImageTimestamp, setBrandImageTimestamp] = useState(Date.now());
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
const [errorFromState, setErrorFromState] = useState('');
|
||||
|
||||
this.state = {
|
||||
deleteBrandImage: false,
|
||||
brandImageExists: false,
|
||||
brandImageTimestamp: Date.now(),
|
||||
error: '',
|
||||
};
|
||||
const handleSave = useCallback(async () => {
|
||||
setErrorFromState('');
|
||||
|
||||
this.imageRef = React.createRef();
|
||||
this.fileInputRef = React.createRef();
|
||||
}
|
||||
let error;
|
||||
if (shouldDeleteBrandImage) {
|
||||
await deleteBrandImage(
|
||||
() => {
|
||||
setShouldDeleteBrandImage(false);
|
||||
setBrandImageExists(false);
|
||||
setBrandImage(undefined);
|
||||
},
|
||||
(err: Error) => {
|
||||
error = err;
|
||||
setErrorFromState(err.message);
|
||||
},
|
||||
);
|
||||
} else if (brandImage) {
|
||||
await uploadBrandImage(
|
||||
brandImage,
|
||||
() => {
|
||||
setBrandImageExists(true);
|
||||
setBrandImage(undefined);
|
||||
setBrandImageTimestamp(Date.now());
|
||||
},
|
||||
(err: Error) => {
|
||||
error = err;
|
||||
setErrorFromState(err.message);
|
||||
},
|
||||
);
|
||||
}
|
||||
return {error};
|
||||
}, [brandImage, shouldDeleteBrandImage]);
|
||||
|
||||
componentDidMount() {
|
||||
useEffect(() => {
|
||||
fetch(
|
||||
Client4.getBrandImageUrl(String(this.state.brandImageTimestamp)),
|
||||
Client4.getBrandImageUrl(String(Date.now())),
|
||||
).then((resp) => {
|
||||
if (resp.status === HTTP_STATUS_OK) {
|
||||
this.setState({brandImageExists: true});
|
||||
setBrandImageExists(true);
|
||||
} else {
|
||||
this.setState({brandImageExists: false});
|
||||
setBrandImageExists(false);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(`unable to retrieve brand image: ${err}`); //eslint-disable-line no-console
|
||||
this.setState({brandImageExists: false});
|
||||
setBrandImageExists(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
this.props.registerSaveAction(this.handleSave);
|
||||
}
|
||||
useEffect(() => {
|
||||
registerSaveAction(handleSave);
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.unRegisterSaveAction(this.handleSave);
|
||||
}
|
||||
return () => {
|
||||
unRegisterSaveAction(handleSave);
|
||||
};
|
||||
}, [handleSave, registerSaveAction, unRegisterSaveAction]);
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.imageRef.current) {
|
||||
useDidUpdate(() => {
|
||||
if (imageRef.current) {
|
||||
const reader = new FileReader();
|
||||
|
||||
const img = this.imageRef.current;
|
||||
const img = imageRef.current;
|
||||
reader.onload = (e) => {
|
||||
const src =
|
||||
e.target?.result instanceof ArrayBuffer ? e.target?.result.toString() : e.target?.result;
|
||||
e.target?.result instanceof ArrayBuffer ? e.target?.result.toString() : e.target?.result;
|
||||
|
||||
if (src) {
|
||||
img.setAttribute('src', src);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.state.brandImage) {
|
||||
reader.readAsDataURL(this.state.brandImage);
|
||||
if (brandImage) {
|
||||
reader.readAsDataURL(brandImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [brandImage]);
|
||||
|
||||
handleSelectClick = () => {
|
||||
this.fileInputRef.current?.click();
|
||||
};
|
||||
const handleSelectClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
handleImageChange = () => {
|
||||
if (!this.fileInputRef.current) {
|
||||
const handleImageChange = useCallback(() => {
|
||||
if (!fileInputRef.current) {
|
||||
return;
|
||||
}
|
||||
const element = this.fileInputRef.current;
|
||||
const element = fileInputRef.current;
|
||||
if (element.files && element.files.length > 0) {
|
||||
this.props.setSaveNeeded();
|
||||
this.setState({
|
||||
brandImage: element.files[0],
|
||||
deleteBrandImage: false,
|
||||
});
|
||||
setSaveNeeded();
|
||||
setBrandImage(element.files[0]);
|
||||
setShouldDeleteBrandImage(false);
|
||||
}
|
||||
};
|
||||
}, [setSaveNeeded]);
|
||||
|
||||
handleDeleteButtonPressed = () => {
|
||||
this.setState({
|
||||
deleteBrandImage: true,
|
||||
brandImage: undefined,
|
||||
brandImageExists: false,
|
||||
});
|
||||
this.props.setSaveNeeded();
|
||||
};
|
||||
const handleDeleteButtonPressed = useCallback(() => {
|
||||
setShouldDeleteBrandImage(true);
|
||||
setBrandImage(undefined);
|
||||
setBrandImageExists(false);
|
||||
|
||||
handleSave = async () => {
|
||||
this.setState({
|
||||
error: '',
|
||||
});
|
||||
setSaveNeeded();
|
||||
}, [setSaveNeeded]);
|
||||
|
||||
let error;
|
||||
if (this.state.deleteBrandImage) {
|
||||
await deleteBrandImage(
|
||||
() => {
|
||||
this.setState({
|
||||
deleteBrandImage: false,
|
||||
brandImageExists: false,
|
||||
brandImage: undefined,
|
||||
});
|
||||
},
|
||||
(err: Error) => {
|
||||
error = err;
|
||||
this.setState({
|
||||
error: err.message,
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (this.state.brandImage) {
|
||||
await uploadBrandImage(
|
||||
this.state.brandImage,
|
||||
() => {
|
||||
this.setState({
|
||||
brandImageExists: true,
|
||||
brandImage: undefined,
|
||||
brandImageTimestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
(err: Error) => {
|
||||
error = err;
|
||||
this.setState({
|
||||
error: err.message,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
return {error};
|
||||
};
|
||||
|
||||
render() {
|
||||
let img = null;
|
||||
if (this.state.brandImage) {
|
||||
img = (
|
||||
<div className='remove-image__img mb-5'>
|
||||
<img
|
||||
ref={this.imageRef}
|
||||
alt='brand image'
|
||||
src=''
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.brandImageExists) {
|
||||
let overlay;
|
||||
if (!this.props.disabled) {
|
||||
overlay = (
|
||||
<WithTooltip
|
||||
title={(
|
||||
<FormattedMessage
|
||||
id='admin.team.removeBrandImage'
|
||||
defaultMessage='Remove brand image'
|
||||
/>
|
||||
)}
|
||||
isVertical={false}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='remove-image__btn'
|
||||
onClick={this.handleDeleteButtonPressed}
|
||||
>
|
||||
<span aria-hidden={true}>{'×'}</span>
|
||||
</button>
|
||||
</WithTooltip>
|
||||
);
|
||||
}
|
||||
img = (
|
||||
<div className='remove-image__img mb-5'>
|
||||
<img
|
||||
alt='brand image'
|
||||
src={Client4.getBrandImageUrl(
|
||||
String(this.state.brandImageTimestamp),
|
||||
)}
|
||||
/>
|
||||
{overlay}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
img = (
|
||||
<p className='mt-2'>
|
||||
<FormattedMessage
|
||||
id='admin.team.noBrandImage'
|
||||
defaultMessage='No brand image uploaded'
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingSet
|
||||
inputId={this.props.id}
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id='admin.team.uploadDesc'
|
||||
defaultMessage='Customize your user experience by adding a custom image to your login screen. Recommended maximum image size is less than 2 MB.'
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='admin.team.brandImageTitle'
|
||||
defaultMessage='Custom Brand Image:'
|
||||
/>
|
||||
}
|
||||
setByEnv={false}
|
||||
>
|
||||
<div>
|
||||
<div className='remove-image'>{img}</div>
|
||||
</div>
|
||||
<div className='file__upload mt-5'>
|
||||
let img = null;
|
||||
if (brandImage) {
|
||||
img = (
|
||||
<div className='remove-image__img mb-5'>
|
||||
<img
|
||||
ref={imageRef}
|
||||
alt='brand image'
|
||||
src=''
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (brandImageExists) {
|
||||
let overlay;
|
||||
if (!disabled) {
|
||||
overlay = (
|
||||
<WithTooltip
|
||||
title={(
|
||||
<FormattedMessage
|
||||
id='admin.team.removeBrandImage'
|
||||
defaultMessage='Remove brand image'
|
||||
/>
|
||||
)}
|
||||
isVertical={false}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-tertiary'
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.handleSelectClick}
|
||||
className='remove-image__btn'
|
||||
data-testid='remove-image__btn'
|
||||
onClick={handleDeleteButtonPressed}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.team.chooseImage'
|
||||
defaultMessage='Select Image'
|
||||
/>
|
||||
<span aria-hidden={true}>{'×'}</span>
|
||||
</button>
|
||||
<input
|
||||
ref={this.fileInputRef}
|
||||
type='file'
|
||||
accept={Constants.ACCEPT_STATIC_IMAGE}
|
||||
disabled={this.props.disabled}
|
||||
onChange={this.handleImageChange}
|
||||
/>
|
||||
</div>
|
||||
<FormError error={this.state.error}/>
|
||||
</SettingSet>
|
||||
</WithTooltip>
|
||||
);
|
||||
}
|
||||
img = (
|
||||
<div className='remove-image__img mb-5'>
|
||||
<img
|
||||
alt='brand image'
|
||||
src={Client4.getBrandImageUrl(
|
||||
String(brandImageTimestamp),
|
||||
)}
|
||||
/>
|
||||
{overlay}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
img = (
|
||||
<p className='mt-2'>
|
||||
<FormattedMessage
|
||||
id='admin.team.noBrandImage'
|
||||
defaultMessage='No brand image uploaded'
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingSet
|
||||
inputId={id}
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id='admin.team.uploadDesc'
|
||||
defaultMessage='Customize your user experience by adding a custom image to your login screen. Recommended maximum image size is less than 2 MB.'
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='admin.team.brandImageTitle'
|
||||
defaultMessage='Custom Brand Image:'
|
||||
/>
|
||||
}
|
||||
setByEnv={false}
|
||||
>
|
||||
<div>
|
||||
<div className='remove-image'>{img}</div>
|
||||
</div>
|
||||
<div className='file__upload mt-5'>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-tertiary'
|
||||
disabled={disabled}
|
||||
onClick={handleSelectClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.team.chooseImage'
|
||||
defaultMessage='Select Image'
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
data-testid='file__upload-input'
|
||||
type='file'
|
||||
accept={Constants.ACCEPT_STATIC_IMAGE}
|
||||
disabled={disabled}
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
</div>
|
||||
<FormError error={errorFromState}/>
|
||||
</SettingSet>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(BrandImageSetting);
|
||||
|
|
|
|||
|
|
@ -237,6 +237,12 @@ const initFormValues = (form: AppForm, timezone?: string): AppFormValues => {
|
|||
};
|
||||
|
||||
export class AppsForm extends React.PureComponent<Props, State> {
|
||||
// Cache sanitized fields to preserve object identity across renders.
|
||||
// Without this, createSanitizedField() returns a new object every render,
|
||||
// causing AppsFormSelectField to remount its AsyncSelect (via refreshNonce),
|
||||
// which re-triggers dynamic select lookups on every keystroke in any field.
|
||||
private sanitizedFieldCache = new Map<AppField, AppField>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
|
|
@ -271,6 +277,14 @@ export class AppsForm extends React.PureComponent<Props, State> {
|
|||
return null;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
// Clear sanitized field cache when the form changes (e.g., refresh or multistep)
|
||||
// so stale entries don't accumulate.
|
||||
if (prevProps.form !== this.props.form) {
|
||||
this.sanitizedFieldCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
updateErrors = (elements: DialogElement[], fieldErrors?: {[x: string]: string}, formError?: string): boolean => {
|
||||
let hasErrors = false;
|
||||
const state = {} as State;
|
||||
|
|
@ -657,8 +671,13 @@ export class AppsForm extends React.PureComponent<Props, State> {
|
|||
}
|
||||
|
||||
return fields.filter((f) => f.name !== form.submit_buttons).map((originalField, index) => {
|
||||
// Use sanitized field for safe usage in components
|
||||
const field = createSanitizedField(originalField);
|
||||
// Use cached sanitized field to preserve object identity across renders.
|
||||
// This prevents AsyncSelect remounts that trigger spurious lookup calls.
|
||||
let field = this.sanitizedFieldCache.get(originalField);
|
||||
if (!field) {
|
||||
field = createSanitizedField(originalField);
|
||||
this.sanitizedFieldCache.set(originalField, field);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppsFormField
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {AppField, AppFormValue, AppSelectOption} from '@mattermost/types/apps';
|
||||
import {isAppSelectOption, type AppField, type AppFormValue, type AppSelectOption} from '@mattermost/types/apps';
|
||||
import type {UserAutocomplete} from '@mattermost/types/autocomplete';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
|
|
@ -176,7 +176,9 @@ export default class AppsFormField extends React.PureComponent<Props> {
|
|||
);
|
||||
}
|
||||
case AppFieldTypes.RADIO: {
|
||||
const radioValue = value as string;
|
||||
// Radio values may be stored as AppSelectOption objects (from initial default)
|
||||
// or plain strings (after user interaction via RadioSetting.onChange)
|
||||
const radioValue = isAppSelectOption(value) ? value.value : (value as string) ?? '';
|
||||
return (
|
||||
<RadioSetting
|
||||
id={name}
|
||||
|
|
|
|||
|
|
@ -1355,29 +1355,19 @@ describe('components/interactive_dialog/InteractiveDialogAdapter', () => {
|
|||
const mockCall = MockAppsFormContainer.mock.calls[0][0];
|
||||
const submitAdapter = mockCall.actions.doAppSubmit;
|
||||
|
||||
// Test with null value for required field - should not crash
|
||||
// Test with null value for required field - should not crash.
|
||||
// processFormValues normalizes null to '' in accumulatedValues,
|
||||
// so the validation sees an empty string (not null) and does not
|
||||
// produce a required-field error.
|
||||
const result = await submitAdapter({
|
||||
values: {
|
||||
'text-field': 'valid',
|
||||
'required-field': null, // Missing required field
|
||||
'required-field': null, // Cleared field — normalized to ''
|
||||
},
|
||||
});
|
||||
|
||||
// Should complete successfully (null values are simply skipped)
|
||||
// Should complete successfully (null values are normalized to empty strings)
|
||||
expect(result.data?.type).toBe('ok');
|
||||
expect(mockConsole.warn).toHaveBeenCalledWith(
|
||||
'[InteractiveDialogAdapter]',
|
||||
'Form submission validation errors',
|
||||
expect.objectContaining({
|
||||
errorCount: expect.any(Number),
|
||||
errors: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: expect.stringContaining('required-field'),
|
||||
message: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2305,5 +2295,131 @@ describe('components/interactive_dialog/InteractiveDialogAdapter', () => {
|
|||
data: {items: []},
|
||||
});
|
||||
});
|
||||
|
||||
test('should include selected_field in refresh submission', async () => {
|
||||
const mockSubmitDialog = jest.fn().mockResolvedValue({data: {}});
|
||||
|
||||
const refreshSelectElement: DialogElement = {
|
||||
name: 'category',
|
||||
type: 'select',
|
||||
display_name: 'Category',
|
||||
help_text: '',
|
||||
placeholder: '',
|
||||
default: '',
|
||||
optional: false,
|
||||
max_length: 0,
|
||||
min_length: 0,
|
||||
subtype: '',
|
||||
data_source: '',
|
||||
options: [
|
||||
{text: 'Option A', value: 'a'},
|
||||
{text: 'Option B', value: 'b'},
|
||||
],
|
||||
};
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
sourceUrl: '/plugins/myplugin/refresh',
|
||||
elements: [refreshSelectElement],
|
||||
actions: {
|
||||
submitInteractiveDialog: mockSubmitDialog,
|
||||
lookupInteractiveDialog: jest.fn().mockResolvedValue({data: {items: []}}),
|
||||
},
|
||||
};
|
||||
|
||||
const {getByTestId} = renderWithContext(
|
||||
<InteractiveDialogAdapter {...props}/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('apps-form-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Get the refresh handler from the MockAppsFormContainer
|
||||
const mockCall = MockAppsFormContainer.mock.calls[0][0];
|
||||
const refreshHandler = mockCall.actions.doAppFetchForm;
|
||||
|
||||
// Trigger refresh with selected_field set
|
||||
await refreshHandler({
|
||||
selected_field: 'category',
|
||||
values: {category: 'a'},
|
||||
});
|
||||
|
||||
expect(mockSubmitDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/plugins/myplugin/refresh',
|
||||
submission: expect.objectContaining({
|
||||
selected_field: 'category',
|
||||
category: 'a',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should send empty string for cleared select field values in refresh', async () => {
|
||||
const mockSubmitDialog = jest.fn().mockResolvedValue({data: {}});
|
||||
|
||||
const refreshSelectElement: DialogElement = {
|
||||
name: 'priority',
|
||||
type: 'select',
|
||||
display_name: 'Priority',
|
||||
help_text: '',
|
||||
placeholder: '',
|
||||
default: '',
|
||||
optional: true,
|
||||
max_length: 0,
|
||||
min_length: 0,
|
||||
subtype: '',
|
||||
data_source: '',
|
||||
options: [
|
||||
{text: 'High', value: 'high'},
|
||||
{text: 'Low', value: 'low'},
|
||||
],
|
||||
};
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
sourceUrl: '/plugins/myplugin/refresh',
|
||||
elements: [refreshSelectElement],
|
||||
actions: {
|
||||
submitInteractiveDialog: mockSubmitDialog,
|
||||
lookupInteractiveDialog: jest.fn().mockResolvedValue({data: {items: []}}),
|
||||
},
|
||||
};
|
||||
|
||||
const {getByTestId} = renderWithContext(
|
||||
<InteractiveDialogAdapter {...props}/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('apps-form-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Get the refresh handler from the MockAppsFormContainer
|
||||
const mockCall = MockAppsFormContainer.mock.calls[0][0];
|
||||
const refreshHandler = mockCall.actions.doAppFetchForm;
|
||||
|
||||
// First refresh sets a value so it gets accumulated
|
||||
await refreshHandler({
|
||||
selected_field: 'priority',
|
||||
values: {priority: 'high'},
|
||||
});
|
||||
|
||||
mockSubmitDialog.mockClear();
|
||||
|
||||
// Second refresh clears the field (null simulates a cleared select)
|
||||
await refreshHandler({
|
||||
selected_field: 'priority',
|
||||
values: {priority: null},
|
||||
});
|
||||
|
||||
expect(mockSubmitDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
submission: expect.objectContaining({
|
||||
priority: '',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -169,13 +169,12 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
|
|||
* Common logic for processing form values - used by both submit and refresh
|
||||
*/
|
||||
private processFormValues = (currentValues: Record<string, any>): void => {
|
||||
// Normalize current values to extract primitive values from select objects
|
||||
const normalizedCurrentValues = extractPrimitiveValues(currentValues);
|
||||
|
||||
// Accumulate values: merge current with existing accumulated values
|
||||
// Normalize current values to extract primitive values from select objects.
|
||||
// clearEmptyFields=true ensures cleared fields emit '' or [] so they
|
||||
// overwrite any previously accumulated value for that key.
|
||||
this.accumulatedValues = {
|
||||
...this.accumulatedValues, // Previous steps' values (including other pages)
|
||||
...normalizedCurrentValues, // Current form normalized values
|
||||
...this.accumulatedValues,
|
||||
...extractPrimitiveValues(currentValues, true),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -533,8 +532,15 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
|
|||
const currentValues = call.values || {};
|
||||
this.processFormValues(currentValues);
|
||||
|
||||
// For refresh, send all accumulated normalized values
|
||||
const refreshPayload = this.accumulatedValues;
|
||||
// For refresh, use a shallow copy so that adding selected_field
|
||||
// does not permanently contaminate this.accumulatedValues
|
||||
const refreshPayload = {...this.accumulatedValues};
|
||||
|
||||
// Include the changed field name in the submission so the plugin
|
||||
// knows which field triggered the refresh
|
||||
if (call.selected_field) {
|
||||
refreshPayload.selected_field = call.selected_field;
|
||||
}
|
||||
|
||||
const refreshSubmission: DialogSubmission = {
|
||||
url: this.props.sourceUrl,
|
||||
|
|
|
|||
|
|
@ -408,6 +408,25 @@ describe('components/login/Login', () => {
|
|||
expect(history.push).toHaveBeenCalledWith(redirectPath);
|
||||
});
|
||||
|
||||
it('should not show dont have an account when open server is disabled', () => {
|
||||
const state = mergeObjects(baseState, {
|
||||
entities: {
|
||||
general: {
|
||||
config: {
|
||||
EnableOpenServer: 'false',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithContext(
|
||||
<Login/>,
|
||||
state,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Don\'t have an account')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('EnableGuestMagicLink', () => {
|
||||
it('should show password field when EnableGuestMagicLink is false', async () => {
|
||||
const state = mergeObjects(baseState, {
|
||||
|
|
|
|||
|
|
@ -437,23 +437,18 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
|
|||
}, [sessionExpired, formatMessage, onDismissSessionExpired, extraParam, siteName, searchParam]);
|
||||
|
||||
const getAlternateLink = useCallback(() => {
|
||||
if (!showSignup) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const linkLabel = formatMessage({
|
||||
id: 'login.noAccount',
|
||||
defaultMessage: 'Don\'t have an account?',
|
||||
});
|
||||
if (showSignup) {
|
||||
return (
|
||||
<AlternateLinkLayout
|
||||
className='login-body-alternate-link'
|
||||
alternateLinkPath={'/signup_user_complete'}
|
||||
alternateLinkLabel={linkLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AlternateLinkLayout
|
||||
className='login-body-alternate-link'
|
||||
alternateLinkPath={'/access_problem'}
|
||||
alternateLinkPath={'/signup_user_complete'}
|
||||
alternateLinkLabel={linkLabel}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ const MobileViewWatcher = makeAsyncComponent('MobileViewWatcher', lazy(() => imp
|
|||
const WindowSizeObserver = makeAsyncComponent('WindowSizeObserver', lazy(() => import('components/window_size_observer/WindowSizeObserver')));
|
||||
const ErrorPage = makeAsyncComponent('ErrorPage', lazy(() => import('components/error_page')));
|
||||
const Login = makeAsyncComponent('LoginController', lazy(() => import('components/login/login')));
|
||||
const AccessProblem = makeAsyncComponent('AccessProblem', lazy(() => import('components/access_problem')));
|
||||
const PasswordResetSendLink = makeAsyncComponent('PasswordResedSendLink', lazy(() => import('components/password_reset_send_link')));
|
||||
const PasswordResetForm = makeAsyncComponent('PasswordResetForm', lazy(() => import('components/password_reset_form')));
|
||||
const Signup = makeAsyncComponent('SignupController', lazy(() => import('components/signup/signup')));
|
||||
|
|
@ -320,10 +319,6 @@ export default class Root extends React.PureComponent<Props, State> {
|
|||
path={'/login'}
|
||||
component={Login}
|
||||
/>
|
||||
<HFRoute
|
||||
path={'/access_problem'}
|
||||
component={AccessProblem}
|
||||
/>
|
||||
<HFTRoute
|
||||
path={'/reset_password'}
|
||||
component={PasswordResetSendLink}
|
||||
|
|
|
|||
|
|
@ -5143,8 +5143,6 @@
|
|||
"login.cardtitle": "Log in",
|
||||
"login.cardtitle.external": "Log in with one of the following:",
|
||||
"login.changed": "Sign-in method changed successfully",
|
||||
"login.contact_admin.detail": "To access your team's workspace, contact your workspace admin. If you've been invited already, check your email inbox for a Mattermost workspace invite.",
|
||||
"login.contact_admin.title": "Contact your workspace admin",
|
||||
"login.createTeam": "Create a team",
|
||||
"login.deactivatedUser": "Your account has been deactivated.",
|
||||
"login.defaultError": "We were unable to log you in. Please enter your details and try again.",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
convertDialogToAppForm,
|
||||
convertAppFormValuesToDialogSubmission,
|
||||
DialogElementTypes,
|
||||
extractPrimitiveValues,
|
||||
getDefaultValue,
|
||||
getFieldType,
|
||||
getOptions,
|
||||
|
|
@ -335,10 +336,24 @@ describe('dialog_conversion', () => {
|
|||
} as DialogElement;
|
||||
|
||||
const result = getDefaultValue(element);
|
||||
expect(result).toEqual({
|
||||
label: 'Option 1',
|
||||
value: 'option1',
|
||||
});
|
||||
|
||||
// Radio defaults are plain strings (not {label, value} objects)
|
||||
// because RadioSetting.onChange returns e.target.value (a string)
|
||||
expect(result).toBe('option1');
|
||||
});
|
||||
|
||||
it('should return null for radio default that does not match any option', () => {
|
||||
const element = {
|
||||
type: 'radio',
|
||||
default: 'stale_value',
|
||||
options: [
|
||||
{text: 'Option 1', value: 'option1'},
|
||||
{text: 'Option 2', value: 'option2'},
|
||||
],
|
||||
} as DialogElement;
|
||||
|
||||
const result = getDefaultValue(element);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle dynamic select defaults', () => {
|
||||
|
|
@ -973,6 +988,48 @@ describe('dialog_conversion', () => {
|
|||
expect(form.fields?.[0].refresh).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle refresh property for date and datetime fields', () => {
|
||||
const elements: DialogElement[] = [
|
||||
{
|
||||
name: 'refreshable_date',
|
||||
type: 'date',
|
||||
display_name: 'Refreshable Date',
|
||||
optional: false,
|
||||
refresh: true,
|
||||
} as DialogElement,
|
||||
{
|
||||
name: 'refreshable_datetime',
|
||||
type: 'datetime',
|
||||
display_name: 'Refreshable Datetime',
|
||||
optional: false,
|
||||
refresh: true,
|
||||
} as DialogElement,
|
||||
{
|
||||
name: 'non_refreshable_date',
|
||||
type: 'date',
|
||||
display_name: 'Non-Refreshable Date',
|
||||
optional: false,
|
||||
} as DialogElement,
|
||||
];
|
||||
|
||||
const {form, errors} = convertDialogToAppForm(
|
||||
elements,
|
||||
'Test Dialog',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'http://example.com/source',
|
||||
'',
|
||||
legacyOptions,
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(form.fields).toHaveLength(3);
|
||||
expect(form.fields?.[0].refresh).toBe(true);
|
||||
expect(form.fields?.[1].refresh).toBe(true);
|
||||
expect(form.fields?.[2].refresh).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include state in submit and source AppCall objects', () => {
|
||||
const elements: DialogElement[] = [
|
||||
{
|
||||
|
|
@ -1234,6 +1291,48 @@ describe('dialog_conversion', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should extract value from radio field stored as AppSelectOption object', () => {
|
||||
const values = {
|
||||
radio_object: {label: 'Option A', value: 'optA'},
|
||||
radio_string: 'optB',
|
||||
} as unknown as AppFormValues;
|
||||
|
||||
const elements: DialogElement[] = [
|
||||
{
|
||||
name: 'radio_object',
|
||||
type: 'radio',
|
||||
display_name: 'Radio Object Field',
|
||||
optional: false,
|
||||
options: [
|
||||
{text: 'Option A', value: 'optA'},
|
||||
{text: 'Option B', value: 'optB'},
|
||||
],
|
||||
} as DialogElement,
|
||||
{
|
||||
name: 'radio_string',
|
||||
type: 'radio',
|
||||
display_name: 'Radio String Field',
|
||||
optional: false,
|
||||
options: [
|
||||
{text: 'Option A', value: 'optA'},
|
||||
{text: 'Option B', value: 'optB'},
|
||||
],
|
||||
} as DialogElement,
|
||||
];
|
||||
|
||||
const {submission, errors} = convertAppFormValuesToDialogSubmission(
|
||||
values,
|
||||
elements,
|
||||
legacyOptions,
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(submission).toEqual({
|
||||
radio_object: 'optA',
|
||||
radio_string: 'optB',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle textarea field values', () => {
|
||||
const values = {
|
||||
textarea_field: 'Long text content',
|
||||
|
|
@ -1682,4 +1781,154 @@ describe('dialog_conversion', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPrimitiveValues', () => {
|
||||
it('should extract value from a single select option', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
color: {label: 'Red', value: 'red'},
|
||||
});
|
||||
expect(result).toEqual({color: 'red'});
|
||||
});
|
||||
|
||||
it('should extract values from a multiselect array', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
colors: [
|
||||
{label: 'Red', value: 'red'},
|
||||
{label: 'Blue', value: 'blue'},
|
||||
],
|
||||
});
|
||||
expect(result).toEqual({colors: ['red', 'blue']});
|
||||
});
|
||||
|
||||
it('should pass through primitive strings', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
name: 'hello',
|
||||
});
|
||||
expect(result).toEqual({name: 'hello'});
|
||||
});
|
||||
|
||||
it('should pass through booleans', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
enabled: true,
|
||||
disabled: false,
|
||||
});
|
||||
expect(result).toEqual({enabled: true, disabled: false});
|
||||
});
|
||||
|
||||
it('should skip null, undefined, empty string, and <nil> values', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
a: null,
|
||||
b: undefined,
|
||||
c: '',
|
||||
d: '<nil>',
|
||||
e: 'keep',
|
||||
});
|
||||
expect(result).toEqual({e: 'keep'});
|
||||
});
|
||||
|
||||
it('should skip select option with empty value', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
color: {label: '', value: ''},
|
||||
});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should skip select option with <nil> value', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
color: {label: 'None', value: '<nil>'},
|
||||
});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should skip empty multiselect arrays', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
colors: [],
|
||||
});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should pass through primitive string arrays', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
dates: ['2026-01-01', '2026-01-15'],
|
||||
});
|
||||
expect(result).toEqual({dates: ['2026-01-01', '2026-01-15']});
|
||||
});
|
||||
|
||||
it('should handle mixed arrays of select options and primitives', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
items: [
|
||||
{label: 'Red', value: 'red'},
|
||||
'already-extracted',
|
||||
],
|
||||
});
|
||||
expect(result).toEqual({items: ['red', 'already-extracted']});
|
||||
});
|
||||
|
||||
it('should filter out meaningless values from multiselect arrays', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
colors: [
|
||||
{label: 'Red', value: 'red'},
|
||||
{label: 'Empty', value: ''},
|
||||
{label: 'Blue', value: 'blue'},
|
||||
],
|
||||
});
|
||||
expect(result).toEqual({colors: ['red', 'blue']});
|
||||
});
|
||||
|
||||
describe('with clearEmptyFields=true', () => {
|
||||
it('should emit empty string for null values', () => {
|
||||
const result = extractPrimitiveValues({a: null}, true);
|
||||
expect(result).toEqual({a: ''});
|
||||
});
|
||||
|
||||
it('should emit empty string for undefined values', () => {
|
||||
const result = extractPrimitiveValues({a: undefined}, true);
|
||||
expect(result).toEqual({a: ''});
|
||||
});
|
||||
|
||||
it('should emit empty string for empty string values', () => {
|
||||
const result = extractPrimitiveValues({a: ''}, true);
|
||||
expect(result).toEqual({a: ''});
|
||||
});
|
||||
|
||||
it('should emit empty string for <nil> values', () => {
|
||||
const result = extractPrimitiveValues({a: '<nil>'}, true);
|
||||
expect(result).toEqual({a: ''});
|
||||
});
|
||||
|
||||
it('should emit empty array for empty multiselect arrays', () => {
|
||||
const result = extractPrimitiveValues({colors: []}, true);
|
||||
expect(result).toEqual({colors: []});
|
||||
});
|
||||
|
||||
it('should emit empty string for select option with empty value', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
color: {label: '', value: ''},
|
||||
}, true);
|
||||
expect(result).toEqual({color: ''});
|
||||
});
|
||||
|
||||
it('should emit empty string for select option with <nil> value', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
color: {label: 'None', value: '<nil>'},
|
||||
}, true);
|
||||
expect(result).toEqual({color: ''});
|
||||
});
|
||||
|
||||
it('should still extract meaningful values normally', () => {
|
||||
const result = extractPrimitiveValues({
|
||||
name: 'hello',
|
||||
color: {label: 'Red', value: 'red'},
|
||||
cleared: null,
|
||||
emptied: [],
|
||||
}, true);
|
||||
expect(result).toEqual({
|
||||
name: 'hello',
|
||||
color: 'red',
|
||||
cleared: '',
|
||||
emptied: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {AppForm, AppField, AppFormValue, AppSelectOption, AppFormValues} from '@mattermost/types/apps';
|
||||
import {isAppSelectOption, type AppForm, type AppField, type AppFormValue, type AppSelectOption, type AppFormValues} from '@mattermost/types/apps';
|
||||
import type {DialogElement} from '@mattermost/types/integrations';
|
||||
|
||||
import {AppFieldTypes} from 'mattermost-redux/constants/apps';
|
||||
|
|
@ -253,10 +253,19 @@ export function getDefaultValue(element: DialogElement): AppFormValue {
|
|||
return boolString === 'true' || boolString === '1' || boolString === 'yes';
|
||||
}
|
||||
|
||||
case DialogElementTypes.SELECT:
|
||||
case DialogElementTypes.RADIO: {
|
||||
// Radio values are always plain strings (RadioSetting.onChange returns e.target.value).
|
||||
// Normalize to string from the start so the value shape never changes after user interaction.
|
||||
if (element.options && element.default) {
|
||||
const match = element.options.find((opt) => opt.value === element.default);
|
||||
return match ? match.value : null;
|
||||
}
|
||||
return element.default ? String(element.default) : null;
|
||||
}
|
||||
|
||||
case DialogElementTypes.SELECT: {
|
||||
// Handle dynamic selects that use data_source instead of static options
|
||||
if (element.type === 'select' && element.data_source === 'dynamic' && element.default) {
|
||||
if (element.data_source === 'dynamic' && element.default) {
|
||||
if (element.multiselect) {
|
||||
const values = Array.isArray(element.default) ?
|
||||
element.default :
|
||||
|
|
@ -274,7 +283,7 @@ export function getDefaultValue(element: DialogElement): AppFormValue {
|
|||
|
||||
if (element.options && element.default) {
|
||||
// Handle multiselect defaults (comma-separated values)
|
||||
if (element.type === 'select' && element.multiselect) {
|
||||
if (element.multiselect) {
|
||||
const defaultValues = Array.isArray(element.default) ? element.default : String(element.default).split(',').map((val) => val.trim());
|
||||
|
||||
const defaultOptions = defaultValues.map((value) => {
|
||||
|
|
@ -439,6 +448,13 @@ export function convertElement(element: DialogElement, options: ConversionOption
|
|||
}
|
||||
}
|
||||
|
||||
// Add refresh support for bool fields
|
||||
if (element.type === DialogElementTypes.BOOL) {
|
||||
if (element.refresh !== undefined) {
|
||||
appField.refresh = element.refresh;
|
||||
}
|
||||
}
|
||||
|
||||
// Add date/datetime specific properties
|
||||
if (element.type === DialogElementTypes.DATE || element.type === DialogElementTypes.DATETIME) {
|
||||
// Use datetime_config if provided
|
||||
|
|
@ -456,6 +472,10 @@ export function convertElement(element: DialogElement, options: ConversionOption
|
|||
if (element.time_interval !== undefined && element.type === DialogElementTypes.DATETIME) {
|
||||
appField.time_interval = Number(element.time_interval);
|
||||
}
|
||||
|
||||
if (element.refresh !== undefined) {
|
||||
appField.refresh = element.refresh;
|
||||
}
|
||||
}
|
||||
|
||||
return {field: appField, errors};
|
||||
|
|
@ -567,42 +587,34 @@ export function convertDialogToAppForm(
|
|||
/**
|
||||
* Extract primitive values from form field objects for storage/submission
|
||||
* Converts select option objects {label: "Text", value: "val"} to primitive "val"
|
||||
* Filters out null, undefined, empty, and "<nil>" values
|
||||
* Filters out null, undefined, empty, and "<nil>" values unless clearEmptyFields is true,
|
||||
* in which case empty/null fields are emitted as '' or [] so they can overwrite prior values.
|
||||
*/
|
||||
export function extractPrimitiveValues(values: Record<string, any>): Record<string, any> {
|
||||
const normalized: Record<string, any> = {};
|
||||
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
// Skip null, undefined, empty string, and "<nil>" values
|
||||
if (value === null || value === undefined || value === '' || value === '<nil>') {
|
||||
return;
|
||||
}
|
||||
export function extractPrimitiveValues(values: Record<string, any>, clearEmptyFields = false): Record<string, any> {
|
||||
const isMeaningful = (v: any): v is string | boolean => v != null && v !== '' && v !== '<nil>';
|
||||
|
||||
return Object.entries(values).reduce<Record<string, any>>((acc, [key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
// Handle multiselect arrays - extract values from each option object
|
||||
const extractedValues = value.
|
||||
filter((item) => item && typeof item === 'object' && 'value' in item).
|
||||
map((item) => item.value).
|
||||
filter((val) => val !== null && val !== undefined && val !== '' && val !== '<nil>');
|
||||
const extracted = value.
|
||||
map((item) => (isAppSelectOption(item) ? item.value : item)).
|
||||
filter(isMeaningful);
|
||||
|
||||
if (extractedValues.length > 0) {
|
||||
normalized[key] = extractedValues;
|
||||
if (extracted.length > 0 || clearEmptyFields) {
|
||||
acc[key] = extracted;
|
||||
}
|
||||
} else if (value && typeof value === 'object' && 'value' in value) {
|
||||
// Extract value from single select option object {label: "...", value: "..."}
|
||||
const extractedValue = value.value;
|
||||
|
||||
// Only store if the extracted value is meaningful
|
||||
if (extractedValue !== null && extractedValue !== undefined && extractedValue !== '' && extractedValue !== '<nil>') {
|
||||
normalized[key] = extractedValue;
|
||||
} else if (isAppSelectOption(value)) {
|
||||
if (isMeaningful(value.value)) {
|
||||
acc[key] = value.value;
|
||||
} else if (clearEmptyFields) {
|
||||
acc[key] = '';
|
||||
}
|
||||
} else {
|
||||
// Keep primitive values as-is (but skip empty/nil values)
|
||||
normalized[key] = value;
|
||||
} else if (isMeaningful(value)) {
|
||||
acc[key] = value;
|
||||
} else if (clearEmptyFields) {
|
||||
acc[key] = '';
|
||||
}
|
||||
});
|
||||
|
||||
return normalized;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -693,7 +705,13 @@ export function convertAppFormValuesToDialogSubmission(
|
|||
break;
|
||||
|
||||
case DialogElementTypes.RADIO:
|
||||
submission[element.name] = String(value);
|
||||
// Radio values are normally plain strings, but accept {label, value}
|
||||
// objects for backwards compatibility with older code paths.
|
||||
if (isAppSelectOption(value)) {
|
||||
submission[element.name] = value.value;
|
||||
} else {
|
||||
submission[element.name] = String(value);
|
||||
}
|
||||
break;
|
||||
|
||||
case DialogElementTypes.SELECT:
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@ export type AppSelectOption = {
|
|||
icon_data?: string;
|
||||
};
|
||||
|
||||
function isAppSelectOption(v: unknown): v is AppSelectOption {
|
||||
export function isAppSelectOption(v: unknown): v is AppSelectOption {
|
||||
if (typeof v !== 'object' || v === null) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue