Merge branch 'master' of github.com:mattermost/mattermost-server into MM-50966-in-product-expansion-backend

This commit is contained in:
Conor Macpherson
2023-03-27 13:29:20 -04:00
107 changed files with 2137 additions and 929 deletions

View File

@@ -7,7 +7,7 @@ stages:
include:
- project: mattermost/ci/mattermost-server
ref: monorepo-testing
ref: master
file: private.yml
variables:

View File

@@ -11,8 +11,8 @@ You may be licensed to use source code to create compiled versions not produced
1. Under the Free Software Foundations GNU AGPL v.3.0, subject to the exceptions outlined in this policy; or
2. Under a commercial license available from Mattermost, Inc. by contacting commercial@mattermost.com
You are licensed to use the source code in Admin Tools and Configuration Files (templates/, config/default.json, i18n/, model/,
plugin/ and all subdirectories thereof) under the Apache License v2.0.
You are licensed to use the source code in Admin Tools and Configuration Files (server/templates/, server/i18n/, model/,
plugin/, server/boards/, server/playbooks/, webapp/ and all subdirectories thereof) under the Apache License v2.0.
We promise that we will not enforce the copyleft provisions in AGPL v3.0 against you if your application (a) does not
link to the Mattermost Platform directly, but exclusively uses the Mattermost Admin Tools and Configuration Files, and

View File

@@ -21,10 +21,11 @@ type BootstrapSelfHostedSignupResponseInternal struct {
// email contained in token, so not in the request body.
type SelfHostedCustomerForm struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
BillingAddress *Address `json:"billing_address"`
Organization string `json:"organization"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
BillingAddress *Address `json:"billing_address"`
ShippingAddress *Address `json:"shipping_address"`
Organization string `json:"organization"`
}
type SelfHostedConfirmPaymentMethodRequest struct {

View File

@@ -140,6 +140,7 @@ func TestAddMemberToBoard(t *testing.T) {
}
func TestPatchBoard(t *testing.T) {
t.Skip("MM-51699")
th, tearDown := SetupTestHelper(t)
defer tearDown()

View File

@@ -231,6 +231,9 @@ func (bm *BoardsMigrator) MigrateToStep(step int) error {
func (bm *BoardsMigrator) Interceptors() map[int]foundation.Interceptor {
return map[int]foundation.Interceptor{
18: bm.store.RunDeletedMembershipBoardsMigration,
35: func() error {
return bm.store.RunDeDuplicateCategoryBoardsMigration(35)
},
}
}

View File

@@ -863,10 +863,8 @@ func (s *SQLStore) doesDuplicateCategoryBoardsExist() (bool, error) {
}
func (s *SQLStore) runMySQLDeDuplicateCategoryBoardsMigration() error {
query := "WITH duplicates AS (SELECT id, ROW_NUMBER() OVER(PARTITION BY user_id, board_id) AS rownum " +
"FROM " + s.tablePrefix + "category_boards) " +
"DELETE " + s.tablePrefix + "category_boards FROM " + s.tablePrefix + "category_boards " +
"JOIN duplicates USING(id) WHERE duplicates.rownum > 1;"
query := "DELETE FROM " + s.tablePrefix + "category_boards WHERE id NOT IN " +
"(SELECT * FROM ( SELECT min(id) FROM " + s.tablePrefix + "category_boards GROUP BY user_id, board_id ) as data)"
if _, err := s.db.Exec(query); err != nil {
s.logger.Error("Failed to de-duplicate data in category_boards table", mlog.Err(err))
}

View File

@@ -7,6 +7,9 @@ import (
"testing"
"time"
"github.com/mattermost/mattermost-server/v6/server/boards/services/store/sqlstore/migrationstests"
"github.com/mgdelacroix/foundation"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/stretchr/testify/assert"
@@ -263,3 +266,23 @@ func TestCheckForMismatchedCollation(t *testing.T) {
}
})
}
func TestRunDeDuplicateCategoryBoardsMigration(t *testing.T) {
RunStoreTestsWithFoundation(t, func(t *testing.T, f *foundation.Foundation) {
th, tearDown := migrationstests.SetupTestHelper(t, f)
defer tearDown()
th.F().MigrateToStepSkippingLastInterceptor(35).
ExecFile("./fixtures/testDeDuplicateCategoryBoardsMigration.sql")
th.F().RunInterceptor(35)
// verifying count of rows
var count int
countQuery := "SELECT COUNT(*) FROM focalboard_category_boards"
row := th.F().DB().QueryRow(countQuery)
err := row.Scan(&count)
assert.NoError(t, err)
assert.Equal(t, 4, count)
})
}

View File

@@ -0,0 +1,9 @@
INSERT INTO focalboard_category_boards(id, user_id, category_id, board_id, create_at, update_at, sort_order)
VALUES
('id_1', 'user_id_1', 'category_id_1', 'board_id_1', 0, 0, 0),
('id_2', 'user_id_1', 'category_id_2', 'board_id_1', 0, 0, 0),
('id_3', 'user_id_1', 'category_id_3', 'board_id_1', 0, 0, 0),
('id_4', 'user_id_2', 'category_id_4', 'board_id_2', 0, 0, 0),
('id_5', 'user_id_2', 'category_id_5', 'board_id_2', 0, 0, 0),
('id_6', 'user_id_3', 'category_id_6', 'board_id_3', 0, 0, 0),
('id_7', 'user_id_4', 'category_id_6', 'board_id_4', 0, 0, 0);

View File

@@ -22,6 +22,10 @@ func (th *TestHelper) IsMySQL() bool {
return th.f.DB().DriverName() == "mysql"
}
func (th *TestHelper) F() *foundation.Foundation {
return th.f
}
func SetupTestHelper(t *testing.T, f *foundation.Foundation) (*TestHelper, func()) {
th := &TestHelper{t, f}

View File

@@ -50,6 +50,7 @@ func NewStoreType(name string, driver string, skipMigrations bool) *storeType {
DB: sqlDB,
IsPlugin: false, // ToDo: to be removed
}
store, err := New(storeParams)
if err != nil {
panic(fmt.Sprintf("cannot create store: %s", err))

View File

@@ -17,7 +17,7 @@ import (
)
const (
rudderKey = "placeholder_rudder_key"
rudderKey = "placeholder_boards_rudder_key"
rudderDataplaneURL = "placeholder_rudder_dataplane_url"
timeBetweenTelemetryChecks = 10 * time.Minute
)

View File

@@ -71,10 +71,8 @@ type TestHelper struct {
IncludeCacheLayer bool
LogBuffer *mlog.Buffer
TestLogger *mlog.Logger
boardsProductEnvValue string
playbooksDisableEnvValue string
LogBuffer *mlog.Buffer
TestLogger *mlog.Logger
}
var mainHelper *testlib.MainHelper
@@ -104,17 +102,6 @@ func setupTestHelper(dbStore store.Store, searchEngine *searchengine.Broker, ent
*memoryConfig.AnnouncementSettings.AdminNoticesEnabled = false
*memoryConfig.AnnouncementSettings.UserNoticesEnabled = false
*memoryConfig.PluginSettings.AutomaticPrepackagedPlugins = false
// disable Boards through the feature flag
boardsProductEnvValue := os.Getenv("MM_FEATUREFLAGS_BoardsProduct")
os.Unsetenv("MM_FEATUREFLAGS_BoardsProduct")
memoryConfig.FeatureFlags.BoardsProduct = false
// disable Playbooks (temporarily) as it causes many more mocked methods to get
// called, and cannot receieve a mocked database.
playbooksDisableEnvValue := os.Getenv("MM_DISABLE_PLAYBOOKS")
os.Setenv("MM_DISABLE_PLAYBOOKS", "true")
if updateConfig != nil {
updateConfig(memoryConfig)
}
@@ -153,15 +140,13 @@ func setupTestHelper(dbStore store.Store, searchEngine *searchengine.Broker, ent
}
th := &TestHelper{
App: app.New(app.ServerConnector(s.Channels())),
Server: s,
ConfigStore: configStore,
IncludeCacheLayer: includeCache,
Context: request.EmptyContext(testLogger),
TestLogger: testLogger,
LogBuffer: buffer,
boardsProductEnvValue: boardsProductEnvValue,
playbooksDisableEnvValue: playbooksDisableEnvValue,
App: app.New(app.ServerConnector(s.Channels())),
Server: s,
ConfigStore: configStore,
IncludeCacheLayer: includeCache,
Context: request.EmptyContext(testLogger),
TestLogger: testLogger,
LogBuffer: buffer,
}
th.Context.SetLogger(testLogger)
@@ -386,17 +371,6 @@ func (th *TestHelper) ShutdownApp() {
}
func (th *TestHelper) TearDown() {
// reset board and playbooks product setting to original
if th.boardsProductEnvValue != "" {
os.Setenv("MM_FEATUREFLAGS_BoardsProduct", th.boardsProductEnvValue)
}
if th.playbooksDisableEnvValue != "" {
os.Setenv("MM_DISABLE_PLAYBOOKS", th.playbooksDisableEnvValue)
} else {
os.Unsetenv("MM_DISABLE_PLAYBOOKS")
}
if th.IncludeCacheLayer {
// Clean all the caches
th.App.Srv().InvalidateAllCaches()

View File

@@ -13,6 +13,8 @@ import (
"reflect"
"time"
"github.com/pkg/errors"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
@@ -274,6 +276,10 @@ func selfHostedInvoices(c *Context, w http.ResponseWriter, r *http.Request) {
invoices, err := c.App.Cloud().GetSelfHostedInvoices()
if err != nil {
if err.Error() == "404" {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotFound).Wrap(errors.New("invoices for license not found"))
return
}
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}

View File

@@ -11,8 +11,10 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/store/storetest/mocks"
)
/* Temporarily comment out until MM-11108
@@ -37,9 +39,26 @@ func init() {
}
func TestUnitUpdateConfig(t *testing.T) {
th := Setup(t)
th := SetupWithStoreMock(t)
defer th.TearDown()
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(65535, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
mockLicenseStore := mocks.LicenseStore{}
mockLicenseStore.On("Get", "").Return(&model.LicenseRecord{}, nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("License").Return(&mockLicenseStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
prev := *th.App.Config().ServiceSettings.SiteURL
var called int32

View File

@@ -42,9 +42,7 @@ type TestHelper struct {
TestLogger *mlog.Logger
IncludeCacheLayer bool
tempWorkspace string
boardsProductEnvValue string
playbooksDisableEnvValue string
tempWorkspace string
}
func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer bool, options []Option, tb testing.TB) *TestHelper {
@@ -62,17 +60,6 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo
*memoryConfig.LogSettings.EnableSentry = false // disable error reporting during tests
*memoryConfig.AnnouncementSettings.AdminNoticesEnabled = false
*memoryConfig.AnnouncementSettings.UserNoticesEnabled = false
// disable Boards through the feature flag
boardsProductEnvValue := os.Getenv("MM_FEATUREFLAGS_BoardsProduct")
os.Unsetenv("MM_FEATUREFLAGS_BoardsProduct")
memoryConfig.FeatureFlags.BoardsProduct = false
// disable Playbooks (temporarily) as it causes many more mocked methods to get
// called, and cannot receieve a mocked database.
playbooksDisableEnvValue := os.Getenv("MM_DISABLE_PLAYBOOKS")
os.Setenv("MM_DISABLE_PLAYBOOKS", "true")
configStore.Set(memoryConfig)
buffer := &mlog.Buffer{}
@@ -103,14 +90,12 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo
}
th := &TestHelper{
App: New(ServerConnector(s.Channels())),
Context: request.EmptyContext(testLogger),
Server: s,
LogBuffer: buffer,
TestLogger: testLogger,
IncludeCacheLayer: includeCacheLayer,
boardsProductEnvValue: boardsProductEnvValue,
playbooksDisableEnvValue: playbooksDisableEnvValue,
App: New(ServerConnector(s.Channels())),
Context: request.EmptyContext(testLogger),
Server: s,
LogBuffer: buffer,
TestLogger: testLogger,
IncludeCacheLayer: includeCacheLayer,
}
th.Context.SetLogger(testLogger)
@@ -184,10 +169,16 @@ func SetupWithStoreMock(tb testing.TB) *TestHelper {
statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil)
statusMock.On("UpdateLastActivityAt", "user1", mock.Anything).Return(nil)
statusMock.On("SaveOrUpdate", mock.AnythingOfType("*model.Status")).Return(nil)
pluginMock := mocks.PluginStore{}
pluginMock.On("Get", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(&model.PluginKeyValue{}, nil)
emptyMockStore := mocks.Store{}
emptyMockStore.On("Close").Return(nil)
emptyMockStore.On("Status").Return(&statusMock)
emptyMockStore.On("Plugin").Return(&pluginMock).Maybe()
th.App.Srv().SetStore(&emptyMockStore)
return th
}
@@ -553,17 +544,6 @@ func (th *TestHelper) ShutdownApp() {
}
func (th *TestHelper) TearDown() {
// reset board and playbooks product setting to original
if th.boardsProductEnvValue != "" {
os.Setenv("MM_FEATUREFLAGS_BoardsProduct", th.boardsProductEnvValue)
}
if th.playbooksDisableEnvValue != "" {
os.Setenv("MM_DISABLE_PLAYBOOKS", th.playbooksDisableEnvValue)
} else {
os.Unsetenv("MM_DISABLE_PLAYBOOKS")
}
if th.IncludeCacheLayer {
// Clean all the caches
th.App.Srv().InvalidateAllCaches()

View File

@@ -1445,13 +1445,9 @@ func TestPushNotificationRace(t *testing.T) {
Router: mux.NewRouter(),
}
var err error
s.platform, err = platform.New(
platform.ServiceConfig{
ConfigStore: memoryStore,
},
platform.SetFileStore(&fmocks.FileBackend{}),
platform.StoreOverride(th.GetSqlStore()),
)
s.platform, err = platform.New(platform.ServiceConfig{
ConfigStore: memoryStore,
}, platform.SetFileStore(&fmocks.FileBackend{}))
s.SetStore(mockStore)
require.NoError(t, err)
serviceMap := map[product.ServiceKey]any{

View File

@@ -48,12 +48,12 @@ func (a *App) SaveAdminNotification(userId string, notifyData *model.NotifyAdmin
func (a *App) DoCheckForAdminNotifications(trial bool) *model.AppError {
ctx := request.EmptyContext(a.Srv().Log())
currentSKU := "starter"
license := a.Srv().License()
if license == nil {
return model.NewAppError("DoCheckForAdminNotifications", "app.notify_admin.send_notification_post.app_error", nil, "No license found", http.StatusInternalServerError)
if license != nil {
currentSKU = license.SkuShortName
}
currentSKU := license.SkuShortName
workspaceName := ""
return a.SendNotifyAdminPosts(ctx, workspaceName, currentSKU, trial)

View File

@@ -73,17 +73,15 @@ func (s *Server) initializeProducts(
func (s *Server) shouldStart(product string) bool {
if product == "boards" {
if !s.Config().FeatureFlags.BoardsProduct {
s.Log().Info("Skipping Boards init; disabled via feature flag")
s.Log().Warn("Skipping boards start: not enabled via feature flag")
return false
}
s.Log().Info("Allowing Boards init; enabled via feature flag")
}
if product == "playbooks" {
if os.Getenv("MM_DISABLE_PLAYBOOKS") == "true" {
s.Log().Info("Skipping Playbooks init; disabled via env var")
s.Log().Warn("Skipping playbooks start: disabled via env var")
return false
}
s.Log().Info("Allowing Playbooks init; enabled via env var")
}
return true

View File

@@ -36,9 +36,7 @@ type TestHelper struct {
TestLogger *mlog.Logger
IncludeCacheLayer bool
tempWorkspace string
boardsProductEnvValue string
playbooksDisableEnvValue string
tempWorkspace string
}
func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer bool, tb testing.TB, configSet func(*model.Config)) *TestHelper {
@@ -53,17 +51,6 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo
if configSet != nil {
configSet(memoryConfig)
}
// disable Boards through the feature flag
boardsProductEnvValue := os.Getenv("MM_FEATUREFLAGS_BoardsProduct")
os.Unsetenv("MM_FEATUREFLAGS_BoardsProduct")
memoryConfig.FeatureFlags.BoardsProduct = false
// disable Playbooks (temporarily) as it causes many more mocked methods to get
// called, and cannot receieve a mocked database.
playbooksDisableEnvValue := os.Getenv("MM_DISABLE_PLAYBOOKS")
os.Setenv("MM_DISABLE_PLAYBOOKS", "true")
*memoryConfig.PluginSettings.Directory = filepath.Join(tempWorkspace, "plugins")
*memoryConfig.PluginSettings.ClientDirectory = filepath.Join(tempWorkspace, "webapp")
*memoryConfig.PluginSettings.AutomaticPrepackagedPlugins = false
@@ -95,14 +82,12 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo
}
th := &TestHelper{
App: app.New(app.ServerConnector(s.Channels())),
Context: request.EmptyContext(testLogger),
Server: s,
LogBuffer: buffer,
TestLogger: testLogger,
IncludeCacheLayer: includeCacheLayer,
boardsProductEnvValue: boardsProductEnvValue,
playbooksDisableEnvValue: playbooksDisableEnvValue,
App: app.New(app.ServerConnector(s.Channels())),
Context: request.EmptyContext(testLogger),
Server: s,
LogBuffer: buffer,
TestLogger: testLogger,
IncludeCacheLayer: includeCacheLayer,
}
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.MaxUsersPerTeam = 50 })
@@ -389,17 +374,6 @@ func (th *TestHelper) shutdownApp() {
}
func (th *TestHelper) tearDown() {
// reset board and playbooks product setting to original
if th.boardsProductEnvValue != "" {
os.Setenv("MM_FEATUREFLAGS_BoardsProduct", th.boardsProductEnvValue)
}
if th.playbooksDisableEnvValue != "" {
os.Setenv("MM_DISABLE_PLAYBOOKS", th.playbooksDisableEnvValue)
} else {
os.Unsetenv("MM_DISABLE_PLAYBOOKS")
}
if th.IncludeCacheLayer {
// Clean all the caches
th.App.Srv().InvalidateAllCaches()

View File

@@ -12,7 +12,7 @@ import (
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const installPluginSchedFreq = 1 * time.Minute
const installPluginSchedFreq = 24 * time.Hour
func MakeInstallPluginScheduler(jobServer *jobs.JobServer, license *model.License, jobType string) model.Scheduler {
isEnabled := func(cfg *model.Config) bool {

View File

@@ -48,9 +48,6 @@ type TestHelper struct {
IncludeCacheLayer bool
TestLogger *mlog.Logger
boardsProductEnvValue string
playbooksDisableEnvValue string
}
func SetupWithStoreMock(tb testing.TB) *TestHelper {
@@ -80,17 +77,6 @@ func setupTestHelper(tb testing.TB, includeCacheLayer bool) *TestHelper {
*newConfig.AnnouncementSettings.AdminNoticesEnabled = false
*newConfig.AnnouncementSettings.UserNoticesEnabled = false
*newConfig.PluginSettings.AutomaticPrepackagedPlugins = false
// disable Boards through the feature flag
boardsProductEnvValue := os.Getenv("MM_FEATUREFLAGS_BoardsProduct")
os.Unsetenv("MM_FEATUREFLAGS_BoardsProduct")
newConfig.FeatureFlags.BoardsProduct = false
// disable Playbooks (temporarily) as it causes many more mocked methods to get
// called, and cannot receieve a mocked database.
playbooksDisableEnvValue := os.Getenv("MM_DISABLE_PLAYBOOKS")
os.Setenv("MM_DISABLE_PLAYBOOKS", "true")
memoryStore.Set(newConfig)
var options []app.Option
options = append(options, app.ConfigStore(memoryStore))
@@ -148,14 +134,12 @@ func setupTestHelper(tb testing.TB, includeCacheLayer bool) *TestHelper {
})
th := &TestHelper{
App: a,
Context: request.EmptyContext(testLogger),
Server: s,
Web: web,
IncludeCacheLayer: includeCacheLayer,
TestLogger: testLogger,
boardsProductEnvValue: boardsProductEnvValue,
playbooksDisableEnvValue: playbooksDisableEnvValue,
App: a,
Context: request.EmptyContext(testLogger),
Server: s,
Web: web,
IncludeCacheLayer: includeCacheLayer,
TestLogger: testLogger,
}
th.Context.SetLogger(testLogger)
@@ -194,17 +178,6 @@ func (th *TestHelper) InitBasic() *TestHelper {
}
func (th *TestHelper) TearDown() {
// reset board and playbooks product setting to original
if th.boardsProductEnvValue != "" {
os.Setenv("MM_FEATUREFLAGS_BoardsProduct", th.boardsProductEnvValue)
}
if th.playbooksDisableEnvValue != "" {
os.Setenv("MM_DISABLE_PLAYBOOKS", th.playbooksDisableEnvValue)
} else {
os.Unsetenv("MM_DISABLE_PLAYBOOKS")
}
if th.IncludeCacheLayer {
// Clean all the caches
th.App.Srv().InvalidateAllCaches()

View File

@@ -10013,5 +10013,213 @@
{
"id": "app.oauth.remove_auth_data_by_client_id.app_error",
"translation": "Oauth-Daten können nicht entfernt werden."
},
{
"id": "app.user.run.update_status.title",
"translation": "Aktueller Stand"
},
{
"id": "app.user.run.update_status.submit_label",
"translation": "Status aktualisieren"
},
{
"id": "app.user.run.update_status.reminder_for_next_update",
"translation": "Erinnerung an das nächste Update"
},
{
"id": "app.user.run.update_status.num_channel",
"translation": {
"one": "Bringe die Beteiligten auf den neuesten Stand. Dieser Beitrag wird in einem Kanal veröffentlicht.",
"other": "Bringe die Beteiligten auf den neuesten Stand. Dieser Beitrag wird in {{.Count}} Kanälen veröffentlicht."
}
},
{
"id": "app.user.run.update_status.finish_run.placeholder",
"translation": "Markiere den Durchlauf auch als beendet"
},
{
"id": "app.user.run.update_status.finish_run",
"translation": "Durchlauf beenden"
},
{
"id": "app.user.run.update_status.change_since_last_update",
"translation": "Änderung seit der letzten Aktualisierung"
},
{
"id": "app.user.run.status_enable",
"translation": "@{{.Username}} hat die Statusaktualisierungen für [{{.RunName}}]({{.RunURL}}) aktiviert"
},
{
"id": "app.user.run.status_disable",
"translation": "@{{.Username}} hat die Statusaktualisierungen für [{{.RunName}}]({{.RunURL}}) deaktiviert"
},
{
"id": "app.user.run.request_update",
"translation": "@here — @{{.Name}} hat eine Statusaktualisierung für [{{.RunName}}]({{.RunURL}}) angefordert. \n"
},
{
"id": "app.user.run.request_join_channel",
"translation": "@{{.Name}} ist ein Teilnehmer und möchte diesem Kanal beitreten. Jedes Mitglied des Kanals kann ihn einladen.\n"
},
{
"id": "app.user.run.confirm_finish.title",
"translation": "Beenden des Durchlaufs bestätigen"
},
{
"id": "app.user.run.confirm_finish.submit_label",
"translation": "Durchlauf beenden"
},
{
"id": "app.user.run.confirm_finish.num_outstanding",
"translation": {
"one": "Es gibt **eine offene Aufgabe**. Bist du sicher, dass du den Durchlauf *{{.RunName}}* für alle Teilnehmer beenden willst?",
"other": "Es gibt **{{.Count}} offene Aufgaben**. Bist du sicher, dass du den Durchlauf *{{.RunName}}* für alle Teilnehmer beenden willst?"
}
},
{
"id": "app.user.run.add_to_timeline.title",
"translation": "Zur Zeitleiste hinzufügen"
},
{
"id": "app.user.run.add_to_timeline.summary.placeholder",
"translation": "Kurze Zusammenfassung auf der Zeitachse"
},
{
"id": "app.user.run.add_to_timeline.summary.help",
"translation": "Max. 64 Zeichen"
},
{
"id": "app.user.run.add_to_timeline.summary",
"translation": "Zusammenfassung"
},
{
"id": "app.user.run.add_to_timeline.submit_label",
"translation": "Zur Zeitleiste hinzufügen"
},
{
"id": "app.user.run.add_to_timeline.playbook_run",
"translation": "Playbook-Durchlauf"
},
{
"id": "app.user.run.add_checklist_item.title",
"translation": "Neue Aufgabe hinzufügen"
},
{
"id": "app.user.run.add_checklist_item.submit_label",
"translation": "Aufgabe hinzufügen"
},
{
"id": "app.user.run.add_checklist_item.name",
"translation": "Name"
},
{
"id": "app.user.run.add_checklist_item.description",
"translation": "Beschreibung"
},
{
"id": "app.user.new_run.title",
"translation": "Playbook starten"
},
{
"id": "app.user.new_run.submit_label",
"translation": "Starte Durchlauf"
},
{
"id": "app.user.new_run.run_name",
"translation": "Name des Durchlaufs"
},
{
"id": "app.user.new_run.playbook",
"translation": "Playbook"
},
{
"id": "app.user.new_run.intro",
"translation": "**Eigentümer** {{.Username}}"
},
{
"id": "app.user.digest.tasks.zero_assigned",
"translation": "Du hast keine zugewiesene Aufgabe."
},
{
"id": "app.user.digest.tasks.num_assigned_due_until_today",
"translation": {
"one": "Du hast eine zugewiesene Aufgabe, die jetzt fällig ist:",
"other": "Du hast {{.Count}} zugewiesene Aufgaben, die jetzt fällig sind:"
}
},
{
"id": "app.user.digest.tasks.num_assigned",
"translation": {
"one": "Du hast eine zugewiesene Aufgabe:",
"other": "Du hast {{.Count}} zugewiesene Aufgaben:"
}
},
{
"id": "app.user.digest.tasks.heading",
"translation": "Deine zugewiesenen Aufgaben"
},
{
"id": "app.user.digest.tasks.due_yesterday",
"translation": "Gestern fällig"
},
{
"id": "app.user.digest.tasks.due_x_days_ago",
"translation": "Fällig vor {{.Count}} Tagen"
},
{
"id": "app.user.digest.tasks.due_today",
"translation": "Heute fällig"
},
{
"id": "app.user.digest.tasks.due_in_x_days",
"translation": {
"one": "Fällig in einem Tag",
"other": "Fällig in {{.Count}} Tagen"
}
},
{
"id": "app.user.digest.tasks.due_after_today",
"translation": {
"one": "Du hast **eine zugewiesene Aufgabe, die nach dem heutige Tag fällig ist**.",
"other": "Du hast **{{.Count}} zugewiesene Aufgaben, die nach dem heutige Tag fällig sind**."
}
},
{
"id": "app.user.digest.tasks.all_tasks_command",
"translation": "Bitte benutze `/playbook todo` um alle deine Aufgaben anzuzeigen."
},
{
"id": "app.user.digest.runs_in_progress.zero_in_progress",
"translation": "Du hast keinen aktiven Durchlauf."
},
{
"id": "app.user.digest.runs_in_progress.num_in_progress",
"translation": {
"one": "Du hast einen aktiven Durchlauf:",
"other": "Du hast {{.Count}} aktive Durchläufe:"
}
},
{
"id": "app.user.digest.runs_in_progress.heading",
"translation": "Aktive Durchläufe"
},
{
"id": "app.user.digest.overdue_status_updates.zero_overdue",
"translation": "Du hast keine überfälligen Durchläufe."
},
{
"id": "app.user.digest.overdue_status_updates.num_overdue",
"translation": {
"one": "Du hast einen überfälligen Durchlauf für ein Statusupdate:",
"other": "Du hast {{.Count}} überfällige Durchläufe für ein Statusupdate:"
}
},
{
"id": "app.user.digest.overdue_status_updates.heading",
"translation": "Überfällige Statusaktualisierungen"
},
{
"id": "app.command.execute.error",
"translation": "Kann Befehl nicht ausführen."
}
]

View File

@@ -9546,5 +9546,9 @@
{
"id": "api.admin.syncables_error",
"translation": "Error al agregar usuario a grupo-equipos y grupo-canales"
},
{
"id": "api.command_templates.name",
"translation": "plantillas"
}
]

View File

@@ -9998,5 +9998,213 @@
{
"id": "api.command_templates.unsupported.app_error",
"translation": "あなたのデバイスではテンプレートコマンドはサポートされていません。"
},
{
"id": "app.user.run.update_status.title",
"translation": "ステータスの更新"
},
{
"id": "app.user.run.update_status.submit_label",
"translation": "ステータスを更新"
},
{
"id": "app.user.run.update_status.reminder_for_next_update",
"translation": "次回更新のリマインド"
},
{
"id": "app.user.run.update_status.num_channel",
"translation": {
"other": "関係者に更新内容を提供します。この投稿は {{.Count}} チャンネルに配信されます。"
}
},
{
"id": "app.user.run.update_status.finish_run.placeholder",
"translation": "また、実行を終了としてマークする"
},
{
"id": "app.user.run.update_status.finish_run",
"translation": "実行を終了する"
},
{
"id": "app.user.run.update_status.change_since_last_update",
"translation": "前回更新時からの変更点"
},
{
"id": "app.user.run.status_enable",
"translation": "@{{.Username}} は [{{.RunName}}]({{.RunURL}}) のステータス更新を有効化しました"
},
{
"id": "app.user.run.status_disable",
"translation": "@{{.Username}} は [{{.RunName}}]({{.RunURL}}) のステータス更新を無効化しました"
},
{
"id": "app.user.run.request_update",
"translation": "@here — @{{.Name}} は [{{.RunName}}]({{.RunURL}}) のステータスの更新を要求しました。 \n"
},
{
"id": "app.user.run.request_join_channel",
"translation": "@{{.Name}} は実行の参加者で、このチャンネルへの参加を希望しています。チャンネルのメンバーなら誰でも招待できます。\n"
},
{
"id": "app.user.run.confirm_finish.title",
"translation": "実行終了の確認"
},
{
"id": "app.user.run.confirm_finish.submit_label",
"translation": "実行を終了する"
},
{
"id": "app.user.run.confirm_finish.num_outstanding",
"translation": {
"other": "**{{.Count}} 個の未解決タスク**があります。 本当に実行 *{{.RunName}}* を終了してもよろしいですか?"
}
},
{
"id": "app.user.run.add_to_timeline.title",
"translation": "実行のタイムラインに追加する"
},
{
"id": "app.user.run.add_to_timeline.summary.placeholder",
"translation": "タイムラインに表示される短い要約"
},
{
"id": "app.user.run.add_to_timeline.summary.help",
"translation": "最大 64 文字"
},
{
"id": "app.user.run.add_to_timeline.summary",
"translation": "概要"
},
{
"id": "app.user.run.add_to_timeline.submit_label",
"translation": "実行のタイムラインに追加する"
},
{
"id": "app.user.run.add_to_timeline.playbook_run",
"translation": "Playbookを実行"
},
{
"id": "app.user.run.add_checklist_item.title",
"translation": "新しいタスクを追加"
},
{
"id": "app.user.run.add_checklist_item.submit_label",
"translation": "タスクを追加"
},
{
"id": "app.user.run.add_checklist_item.name",
"translation": "名前"
},
{
"id": "app.user.run.add_checklist_item.description",
"translation": "説明"
},
{
"id": "app.user.new_run.title",
"translation": "Playbookを実行する"
},
{
"id": "app.user.new_run.submit_label",
"translation": "実行開始"
},
{
"id": "app.user.new_run.run_name",
"translation": "実行名"
},
{
"id": "app.user.new_run.playbook",
"translation": "Playbook"
},
{
"id": "app.user.new_run.intro",
"translation": "**オーナー** {{.Username}}"
},
{
"id": "app.user.digest.tasks.zero_assigned",
"translation": "割り当てられたタスクがありません。"
},
{
"id": "app.user.digest.tasks.num_assigned_due_until_today",
"translation": {
"other": "対応期限を迎えている{{.Count}}タスクが割り当てられています:"
}
},
{
"id": "app.user.digest.tasks.num_assigned",
"translation": {
"other": "{{.Count}}タスクが割り当てられています:"
}
},
{
"id": "app.user.digest.tasks.heading",
"translation": "あなたに割り当てられたタスク"
},
{
"id": "app.user.digest.tasks.due_yesterday",
"translation": "昨日で期限切れ"
},
{
"id": "app.user.digest.tasks.due_x_days_ago",
"translation": "{{.Count}}日前に期限切れ"
},
{
"id": "app.user.digest.tasks.due_today",
"translation": "今日が期限です"
},
{
"id": "app.user.digest.tasks.due_in_x_days",
"translation": {
"other": "期限は{{.Count}}日後です"
}
},
{
"id": "app.user.digest.tasks.due_after_today",
"translation": {
"other": "**今日で期限切れとなるタスクが {{.Count}} 件**割り当てられています。"
}
},
{
"id": "app.user.digest.tasks.all_tasks_command",
"translation": "`/playbook todo`を使用すると、あなたのすべてのタスクを確認することができます。"
},
{
"id": "app.user.digest.runs_in_progress.zero_in_progress",
"translation": "進行中の実行はありません。"
},
{
"id": "app.user.digest.runs_in_progress.num_in_progress",
"translation": {
"other": "現在、 {{.Count}} の実行が進行中です:"
}
},
{
"id": "app.user.digest.runs_in_progress.heading",
"translation": "進行中の実行"
},
{
"id": "app.user.digest.overdue_status_updates.zero_overdue",
"translation": "期限切れの実行は 0 です。"
},
{
"id": "app.user.digest.overdue_status_updates.num_overdue",
"translation": {
"other": "ステータス更新の期日が過ぎた実行が {{.Count}} あります:"
}
},
{
"id": "app.user.digest.overdue_status_updates.heading",
"translation": "期限切れステータスの更新"
},
{
"id": "app.oauth.remove_auth_data_by_client_id.app_error",
"translation": "oauth データを削除することができませんでした。"
},
{
"id": "app.command.execute.error",
"translation": "コマンドを実行できませんでした。"
},
{
"id": "api.templates.license_up_for_renewal_contact_sales",
"translation": "営業に問い合わせる"
}
]

View File

@@ -10014,5 +10014,29 @@
{
"id": "app.oauth.remove_auth_data_by_client_id.app_error",
"translation": "Nie można usunąć danych oauth."
},
{
"id": "app.user.digest.runs_in_progress.heading",
"translation": "Uruchomienia w Trakcie"
},
{
"id": "app.user.digest.overdue_status_updates.zero_overdue",
"translation": "Masz 0 zaległych uruchomień."
},
{
"id": "app.user.digest.overdue_status_updates.num_overdue",
"translation": {
"few": "Masz {{.Count}} zaległości w aktualizacji statusu:",
"many": "Masz {{.Count}} zaległości w aktualizacji statusu:",
"one": "Masz {{.Count}} zaległość w aktualizacji statusu:"
}
},
{
"id": "app.user.digest.overdue_status_updates.heading",
"translation": "Zaległe aktualizacje statusu"
},
{
"id": "app.command.execute.error",
"translation": "Nie można wykonać polecenia."
}
]

View File

@@ -9858,5 +9858,353 @@
{
"id": "api.command_templates.unsupported.app_error",
"translation": "您的设备不支持模板命令。"
},
{
"id": "worktemplate.product_teams.sprint_planning.integration",
"translation": "项目面板可以使迭代计划前所未来的容易。频道可以用来对话和保证问题的被关注。迭代计划面板可以使所以有本周对任务的关注,回顾面板让团队作为一个整体不断改进。"
},
{
"id": "worktemplate.product_teams.sprint_planning.channel",
"translation": "项目面板可以使迭代计划前所未来的容易。频道可以用来对话和保证问题的被关注。迭代计划面板可以使所以有本周对任务的关注,回顾面板让团队作为一个整体不断改进。"
},
{
"id": "worktemplate.product_teams.sprint_planning.board",
"translation": "项目面板可以使迭代计划前所未来的容易。频道可以用来对话和保证问题的被关注。迭代计划面板可以使所以有本周对任务的关注,回顾面板让团队作为一个整体不断改进。"
},
{
"id": "worktemplate.product_teams.product_roadmap.channel",
"translation": "这里描述了为什么需要面板"
},
{
"id": "worktemplate.product_teams.product_roadmap.board",
"translation": "这里描述了为什么需要面板"
},
{
"id": "worktemplate.product_teams.goals_and_okrs.integration",
"translation": "清晰的目标对团队的成功至关重要在此项目里你可以在文档里写下团队的目标和OKR并在相关的频道里会有消息提醒。"
},
{
"id": "worktemplate.product_teams.goals_and_okrs.channel",
"translation": "清晰的目标对团队的成功至关重要在此项目里你可以在文档里写下团队的目标和OKR并在相关的频道里会有消息提醒。"
},
{
"id": "worktemplate.product_teams.goals_and_okrs.board",
"translation": "清晰的目标对团队的成功至关重要在此项目里你可以在文档里写下团队的目标和OKR并在相关的频道里会有消息提醒。"
},
{
"id": "worktemplate.product_teams.feature_release.description.playbook",
"translation": "通过建立透明的跨越整个研发团队的工作流程确保你的功能开发过程完美流畅。"
},
{
"id": "worktemplate.product_teams.feature_release.description.integration",
"translation": "在你的频道通过集成Jira和Gtihub机器人提高效率。这些会自动下载安装。"
},
{
"id": "worktemplate.product_teams.feature_release.description.channel",
"translation": "Boards,Playbooks和应用Bot可以很容易地接入功能发布频道并且和你的团队进行相关互动和讨论。"
},
{
"id": "worktemplate.product_teams.feature_release.description.board",
"translation": "使用我们的会议日程模板安排像站立会议这样的定期会议,使用我们的项目任务面板在一路上管理任务的进度。"
},
{
"id": "worktemplate.product_teams.bug_bash.playbook",
"translation": "把事情安排好并且干掉此项目里的所有bug用包含的Playbook, Board, and Channel推动项目并评估进度。"
},
{
"id": "worktemplate.product_teams.bug_bash.integration",
"translation": "把事情安排好并且干掉此项目里的所有bug用包含的Playbook, Board, and Channel推动项目并评估进度。"
},
{
"id": "worktemplate.product_teams.bug_bash.channel",
"translation": "把事情安排好并且干掉此项目里的所有bug用包含的Playbook, Board, and Channel推动项目并评估进度。"
},
{
"id": "worktemplate.product_teams.bug_bash.board",
"translation": "把事情安排好并且干掉此项目里的所有bug用包含的Playbook, Board, and Channel推动项目并评估进度。"
},
{
"id": "worktemplate.leadership.goals_and_okrs.integration",
"translation": "清晰的目标对团队的成功至关重要在此项目里你可以在文档里写下团队的目标和OKR并在相关的频道里会有消息提醒。"
},
{
"id": "worktemplate.leadership.goals_and_okrs.channel",
"translation": "清晰的目标对团队的成功至关重要在此项目里你可以在文档里写下团队的目标和OKR并在相关的频道里会有消息提醒。"
},
{
"id": "worktemplate.leadership.goals_and_okrs.board",
"translation": "清晰的目标对团队的成功至关重要在此项目里你可以在文档里写下团队的目标和OKR并在相关的频道里会有消息提醒。"
},
{
"id": "worktemplate.devops.product_release.playbook",
"translation": "不要丢失此项目的任何一个步骤。从Playbook的检验清单分离成任务部署并达到项目面板的里程碑。用频道来保持所有人对事情的理解一致。"
},
{
"id": "worktemplate.devops.product_release.channel",
"translation": "不要丢失此项目的任何一个步骤。从Playbook的检验清单分离成任务部署并达到项目面板的里程碑。用频道来保持所有人对事情的理解一致。"
},
{
"id": "worktemplate.devops.product_release.board",
"translation": "不要丢失此项目的任何一个步骤。从Playbook的检验清单分离成任务部署并达到项目面板的里程碑。用频道来保持所有人对事情的理解一致。"
},
{
"id": "worktemplate.devops.incident_resolution.description.playbook",
"translation": "当到处都是问题的时候有一个能够确保一切都尽快回归正确的可重复流程是关键。此项目使用Mattermost提供的一切功能保证火被一步步扑灭以及利益相关者被告知。"
},
{
"id": "worktemplate.devops.incident_resolution.description.channel",
"translation": "当到处都是问题的时候有一个能够确保一切都尽快回归正确的可重复流程是关键。此项目使用Mattermost提供的一切功能保证火被一步步扑灭以及利益相关者被告知。"
},
{
"id": "worktemplate.devops.incident_resolution.description.board",
"translation": "当到处都是问题的时候有一个能够确保一切都尽快回归正确的可重复流程是关键。此项目使用Mattermost提供的一切功能保证火被一步步扑灭以及利益相关者被告知。"
},
{
"id": "worktemplate.companywide.goals_and_okrs.integration",
"translation": "清晰的目标对团队的成功至关重要在此项目里你可以在文档里写下团队的目标和OKR并在相关的频道里会有消息提醒。"
},
{
"id": "worktemplate.companywide.goals_and_okrs.channel",
"translation": "清晰的目标对团队的成功至关重要在此项目里你可以在文档里写下团队的目标和OKR并在相关的频道里会有消息提醒。"
},
{
"id": "worktemplate.companywide.goals_and_okrs.board",
"translation": "清晰的目标对团队的成功至关重要在此项目里你可以在文档里写下团队的目标和OKR并在相关的频道里会有消息提醒。"
},
{
"id": "worktemplate.companywide.create_project.integration",
"translation": "使用此项目面板设计一个路线图,并在产生的频道里就相应话题进行探讨合作。"
},
{
"id": "worktemplate.companywide.create_project.channel",
"translation": "使用此项目面板设计一个路线图,并在产生的频道里就相应话题进行探讨合作。"
},
{
"id": "worktemplate.companywide.create_project.board",
"translation": "使用此项目面板设计一个路线图,并在产生的频道里就相应话题进行探讨合作。"
},
{
"id": "app.user.run.update_status.title",
"translation": "状态更新"
},
{
"id": "app.user.run.update_status.submit_label",
"translation": "更新状态"
},
{
"id": "app.user.run.update_status.reminder_for_next_update",
"translation": "下次更新的提醒"
},
{
"id": "app.user.run.update_status.num_channel",
"translation": {
"other": "为利益相关者提供一次更新提醒。这条提醒将被广播到{{.Count}} 个频道。"
}
},
{
"id": "app.user.run.update_status.finish_run.placeholder",
"translation": "并且标记此运行为已结束"
},
{
"id": "app.user.run.update_status.finish_run",
"translation": "结束运行"
},
{
"id": "app.user.run.update_status.change_since_last_update",
"translation": "对比上次存在更改"
},
{
"id": "app.user.run.status_enable",
"translation": "@{{.Username}} 启用了对 [{{.RunName}}]({{.RunURL}})的状态更新"
},
{
"id": "app.user.run.status_disable",
"translation": "@{{.Username}} 停止用了 [{{.RunName}}]({{.RunURL}})的状态更新。"
},
{
"id": "app.user.run.request_update",
"translation": "@here — @{{.Name}} 请求对 [{{.RunName}}]({{.RunURL}}) 进行状态更新。 \n"
},
{
"id": "app.user.run.request_join_channel",
"translation": "@{{.Name}} 是一个运行的参与者,并且希望要参加这个频道。任何的频道成员都可以邀请他们。\n"
},
{
"id": "app.user.run.confirm_finish.title",
"translation": "确认完成运行"
},
{
"id": "app.user.run.confirm_finish.submit_label",
"translation": "完成运行"
},
{
"id": "app.user.run.confirm_finish.num_outstanding",
"translation": {
"other": "一共有 **{{.Count}} 未完成的任务**. 您确定想为所有的参与者结束*{{.RunName}}*吗?"
}
},
{
"id": "app.user.run.add_to_timeline.title",
"translation": "添加到运行队列"
},
{
"id": "app.user.run.add_to_timeline.summary.placeholder",
"translation": "时间表里显示的简单概要"
},
{
"id": "app.user.run.add_to_timeline.summary.help",
"translation": "最大64个字符"
},
{
"id": "app.user.run.add_to_timeline.summary",
"translation": "概要"
},
{
"id": "app.user.run.add_to_timeline.submit_label",
"translation": "添加到运行队列"
},
{
"id": "app.user.run.add_to_timeline.playbook_run",
"translation": "Playbook运行"
},
{
"id": "app.user.run.add_checklist_item.title",
"translation": "添加新任务"
},
{
"id": "app.user.run.add_checklist_item.submit_label",
"translation": "添加任务"
},
{
"id": "app.user.run.add_checklist_item.name",
"translation": "名字"
},
{
"id": "app.user.run.add_checklist_item.description",
"translation": "描述"
},
{
"id": "app.user.new_run.title",
"translation": "运行playbook"
},
{
"id": "app.user.new_run.submit_label",
"translation": "开始运行"
},
{
"id": "app.user.new_run.run_name",
"translation": "运行名"
},
{
"id": "app.user.new_run.playbook",
"translation": "Playbook"
},
{
"id": "app.user.new_run.intro",
"translation": "**所有者** {{.Username}}"
},
{
"id": "app.user.digest.tasks.zero_assigned",
"translation": "您没有任务。"
},
{
"id": "app.user.digest.tasks.num_assigned_due_until_today",
"translation": {
"other": "您有 {{.Count}} 个任务现在已经逾期:"
}
},
{
"id": "app.user.digest.tasks.num_assigned",
"translation": {
"other": "您一共有{{.Count}}个任务:"
}
},
{
"id": "app.user.digest.tasks.heading",
"translation": "您的任务"
},
{
"id": "app.user.digest.tasks.due_yesterday",
"translation": "于昨天过期"
},
{
"id": "app.user.digest.tasks.due_x_days_ago",
"translation": "{{.Count}}天已过期"
},
{
"id": "app.user.digest.tasks.due_today",
"translation": "将于今天过期"
},
{
"id": "app.user.digest.tasks.due_in_x_days",
"translation": {
"other": "{{.Count}}天后过期"
}
},
{
"id": "app.user.digest.tasks.due_after_today",
"translation": {
"other": "您有 **{{.Count}} 项目任务今天之后将要过期**."
}
},
{
"id": "app.user.digest.tasks.all_tasks_command",
"translation": "请使用`/playbook todo`来查看您所有的任务。"
},
{
"id": "app.user.digest.runs_in_progress.zero_in_progress",
"translation": "您有0项正在运行。"
},
{
"id": "app.user.digest.runs_in_progress.num_in_progress",
"translation": {
"other": "您有{{.Count}}正在运行:"
}
},
{
"id": "app.user.digest.runs_in_progress.heading",
"translation": "正在运行"
},
{
"id": "app.user.digest.overdue_status_updates.zero_overdue",
"translation": "您没有逾期。"
},
{
"id": "app.user.digest.overdue_status_updates.num_overdue",
"translation": {
"other": "您有 {{.Count}} 过期需要状态更新:"
}
},
{
"id": "app.user.digest.overdue_status_updates.heading",
"translation": "过期状态更新"
},
{
"id": "app.oauth.remove_auth_data_by_client_id.app_error",
"translation": "不能删除oauth认证信息。"
},
{
"id": "app.command.execute.error",
"translation": "无法执行命令。"
},
{
"id": "api.templates.license_up_for_renewal_contact_sales",
"translation": "联系销售"
},
{
"id": "api.license.true_up_review.not_allowed_for_cloud",
"translation": "云实例不允许真实性评估"
},
{
"id": "api.license.true_up_review.license_required",
"translation": "真实性评估需要许可证"
},
{
"id": "api.license.true_up_review.get_status_error",
"translation": "无法获取真实的状态记录"
},
{
"id": "api.license.true_up_review.create_error",
"translation": "无法创建真实的状态记录"
}
]

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/mattermost/mattermost-server/v6/model"
@@ -53,12 +54,10 @@ const (
const ServerKey product.ServiceKey = "server"
// These credentials for Rudder need to be populated at build-time,
// passing the following flags to the go build command:
// -ldflags "-X main.rudderDataplaneURL=<url> -X main.rudderWriteKey=<write_key>"
var (
rudderDataplaneURL string
rudderWriteKey string
// These credentials for Rudder need to be replaced at build-time.
const (
rudderDataplaneURL = "placeholder_rudder_dataplane_url"
rudderWriteKey = "placeholder_playbooks_rudder_key"
)
var errServiceTypeAssert = errors.New("type assertion failed")
@@ -157,229 +156,9 @@ func newPlaybooksProduct(services map[product.ServiceKey]interface{}) (product.P
return nil, err
}
logger := logrus.StandardLogger()
ConfigureLogrus(logger, playbooks.logger)
playbooks.server = services[ServerKey].(*mmapp.Server)
playbooks.serviceAdapter = newServiceAPIAdapter(playbooks)
botID, err := playbooks.serviceAdapter.EnsureBot(&model.Bot{
Username: "playbooks",
DisplayName: "Playbooks",
Description: "Playbooks bot.",
OwnerId: "playbooks",
})
if err != nil {
return nil, errors.Wrapf(err, "failed to ensure bot")
}
playbooks.config = config.NewConfigService(playbooks.serviceAdapter)
err = playbooks.config.UpdateConfiguration(func(c *config.Configuration) {
c.BotUserID = botID
c.AdminLogLevel = "debug"
})
if err != nil {
return nil, errors.Wrapf(err, "failed save bot to config")
}
playbooks.handler = api.NewHandler(playbooks.config)
if rudderDataplaneURL == "" || rudderWriteKey == "" {
logrus.Warn("Rudder credentials are not set. Disabling analytics.")
playbooks.telemetryClient = &telemetry.NoopTelemetry{}
} else {
diagnosticID := playbooks.serviceAdapter.GetDiagnosticID()
serverVersion := playbooks.serviceAdapter.GetServerVersion()
playbooks.telemetryClient, err = telemetry.NewRudder(rudderDataplaneURL, rudderWriteKey, diagnosticID, model.BuildHashPlaybooks, serverVersion)
if err != nil {
return nil, errors.Wrapf(err, "failed init telemetry client")
}
}
toggleTelemetry := func() {
diagnosticsFlag := playbooks.serviceAdapter.GetConfig().LogSettings.EnableDiagnostics
telemetryEnabled := diagnosticsFlag != nil && *diagnosticsFlag
if telemetryEnabled {
if err = playbooks.telemetryClient.Enable(); err != nil {
logrus.WithError(err).Error("Telemetry could not be enabled")
}
return
}
if err = playbooks.telemetryClient.Disable(); err != nil {
logrus.WithError(err).Error("Telemetry could not be disabled")
}
}
toggleTelemetry()
playbooks.config.RegisterConfigChangeListener(toggleTelemetry)
apiClient := sqlstore.NewClient(playbooks.serviceAdapter)
playbooks.bot = bot.New(playbooks.serviceAdapter, playbooks.config.GetConfiguration().BotUserID, playbooks.config, playbooks.telemetryClient)
scheduler := cluster.GetJobOnceScheduler(playbooks.serviceAdapter)
sqlStore, err := sqlstore.New(apiClient, scheduler)
if err != nil {
return nil, errors.Wrapf(err, "failed creating the SQL store")
}
playbooks.playbookRunStore = sqlstore.NewPlaybookRunStore(apiClient, sqlStore)
playbooks.playbookStore = sqlstore.NewPlaybookStore(apiClient, sqlStore)
statsStore := sqlstore.NewStatsStore(apiClient, sqlStore)
playbooks.userInfoStore = sqlstore.NewUserInfoStore(sqlStore)
channelActionStore := sqlstore.NewChannelActionStore(apiClient, sqlStore)
categoryStore := sqlstore.NewCategoryStore(apiClient, sqlStore)
playbooks.handler = api.NewHandler(playbooks.config)
playbooks.playbookService = app.NewPlaybookService(playbooks.playbookStore, playbooks.bot, playbooks.telemetryClient, playbooks.serviceAdapter, playbooks.metricsService)
keywordsThreadIgnorer := app.NewKeywordsThreadIgnorer()
playbooks.channelActionService = app.NewChannelActionsService(playbooks.serviceAdapter, playbooks.bot, playbooks.config, channelActionStore, playbooks.playbookService, keywordsThreadIgnorer, playbooks.telemetryClient)
playbooks.categoryService = app.NewCategoryService(categoryStore, playbooks.serviceAdapter, playbooks.telemetryClient)
playbooks.licenseChecker = enterprise.NewLicenseChecker(playbooks.serviceAdapter)
playbooks.playbookRunService = app.NewPlaybookRunService(
playbooks.playbookRunStore,
playbooks.bot,
playbooks.config,
scheduler,
playbooks.telemetryClient,
playbooks.telemetryClient,
playbooks.serviceAdapter,
playbooks.playbookService,
playbooks.channelActionService,
playbooks.licenseChecker,
playbooks.metricsService,
)
if err = scheduler.SetCallback(playbooks.playbookRunService.HandleReminder); err != nil {
logrus.WithError(err).Error("JobOnceScheduler could not add the playbookRunService's HandleReminder")
}
if err = scheduler.Start(); err != nil {
logrus.WithError(err).Error("JobOnceScheduler could not start")
}
// Migrations use the scheduler, so they have to be run after playbookRunService and scheduler have started
mutex, err := cluster.NewMutex(playbooks.serviceAdapter, "IR_dbMutex")
if err != nil {
return nil, errors.Wrapf(err, "failed creating cluster mutex")
}
mutex.Lock()
if err = sqlStore.RunMigrations(); err != nil {
mutex.Unlock()
return nil, errors.Wrapf(err, "failed to run migrations")
}
mutex.Unlock()
playbooks.permissions = app.NewPermissionsService(
playbooks.playbookService,
playbooks.playbookRunService,
playbooks.serviceAdapter,
playbooks.config,
playbooks.licenseChecker,
)
// register collections and topics.
// TODO bump the minimum server version
if err = playbooks.serviceAdapter.RegisterCollectionAndTopic(CollectionTypeRun, TopicTypeStatus); err != nil {
logrus.WithError(err).WithField("collection_type", CollectionTypeRun).WithField("topic_type", TopicTypeStatus).Warnf("failed to register collection and topic")
}
if err = playbooks.serviceAdapter.RegisterCollectionAndTopic(CollectionTypeRun, TopicTypeTask); err != nil {
logrus.WithError(err).WithField("collection_type", CollectionTypeRun).WithField("topic_type", TopicTypeTask).Warnf("failed to register collection and topic")
}
api.NewGraphQLHandler(
playbooks.handler.APIRouter,
playbooks.playbookService,
playbooks.playbookRunService,
playbooks.categoryService,
playbooks.serviceAdapter,
playbooks.config,
playbooks.permissions,
playbooks.playbookStore,
playbooks.licenseChecker,
)
api.NewPlaybookHandler(
playbooks.handler.APIRouter,
playbooks.playbookService,
playbooks.serviceAdapter,
playbooks.config,
playbooks.permissions,
)
api.NewPlaybookRunHandler(
playbooks.handler.APIRouter,
playbooks.playbookRunService,
playbooks.playbookService,
playbooks.permissions,
playbooks.licenseChecker,
playbooks.serviceAdapter,
playbooks.bot,
playbooks.config,
)
api.NewStatsHandler(
playbooks.handler.APIRouter,
playbooks.serviceAdapter,
statsStore,
playbooks.playbookService,
playbooks.permissions,
playbooks.licenseChecker,
)
api.NewBotHandler(
playbooks.handler.APIRouter,
playbooks.serviceAdapter, playbooks.bot,
playbooks.config,
playbooks.playbookRunService,
playbooks.userInfoStore,
)
api.NewTelemetryHandler(
playbooks.handler.APIRouter,
playbooks.playbookRunService,
playbooks.serviceAdapter,
playbooks.telemetryClient,
playbooks.playbookService,
playbooks.telemetryClient,
playbooks.telemetryClient,
playbooks.telemetryClient,
playbooks.permissions,
)
api.NewSignalHandler(
playbooks.handler.APIRouter,
playbooks.serviceAdapter,
playbooks.playbookRunService,
playbooks.playbookService,
keywordsThreadIgnorer,
)
api.NewSettingsHandler(
playbooks.handler.APIRouter,
playbooks.serviceAdapter,
playbooks.config,
)
api.NewActionsHandler(
playbooks.handler.APIRouter,
playbooks.channelActionService,
playbooks.serviceAdapter,
playbooks.permissions,
)
api.NewCategoryHandler(
playbooks.handler.APIRouter,
playbooks.serviceAdapter,
playbooks.categoryService,
playbooks.playbookService,
playbooks.playbookRunService,
)
isTestingEnabled := false
flag := playbooks.serviceAdapter.GetConfig().ServiceSettings.EnableTesting
if flag != nil {
isTestingEnabled = *flag
}
if err = command.RegisterCommands(playbooks.serviceAdapter.RegisterCommand, isTestingEnabled); err != nil {
return nil, errors.Wrapf(err, "failed register commands")
}
return playbooks, nil
}
@@ -531,6 +310,228 @@ func (pp *playbooksProduct) setProductServices(services map[product.ServiceKey]i
}
func (pp *playbooksProduct) Start() error {
logger := logrus.StandardLogger()
ConfigureLogrus(logger, pp.logger)
botID, err := pp.serviceAdapter.EnsureBot(&model.Bot{
Username: "playbooks",
DisplayName: "Playbooks",
Description: "Playbooks bot.",
OwnerId: "playbooks",
})
if err != nil {
return errors.Wrapf(err, "failed to ensure bot")
}
pp.config = config.NewConfigService(pp.serviceAdapter)
err = pp.config.UpdateConfiguration(func(c *config.Configuration) {
c.BotUserID = botID
c.AdminLogLevel = "debug"
})
if err != nil {
return errors.Wrapf(err, "failed save bot to config")
}
pp.handler = api.NewHandler(pp.config)
if strings.HasPrefix(rudderWriteKey, "placeholder_") {
logrus.Warn("Rudder credentials are not set. Disabling analytics.")
pp.telemetryClient = &telemetry.NoopTelemetry{}
} else {
logrus.Info("Rudder credentials are set. Enabling analytics.")
diagnosticID := pp.serviceAdapter.GetDiagnosticID()
serverVersion := pp.serviceAdapter.GetServerVersion()
pp.telemetryClient, err = telemetry.NewRudder(rudderDataplaneURL, rudderWriteKey, diagnosticID, model.BuildHashPlaybooks, serverVersion)
if err != nil {
return errors.Wrapf(err, "failed init telemetry client")
}
}
toggleTelemetry := func() {
diagnosticsFlag := pp.serviceAdapter.GetConfig().LogSettings.EnableDiagnostics
telemetryEnabled := diagnosticsFlag != nil && *diagnosticsFlag
if telemetryEnabled {
if err = pp.telemetryClient.Enable(); err != nil {
logrus.WithError(err).Error("Telemetry could not be enabled")
}
return
}
if err = pp.telemetryClient.Disable(); err != nil {
logrus.WithError(err).Error("Telemetry could not be disabled")
}
}
toggleTelemetry()
pp.config.RegisterConfigChangeListener(toggleTelemetry)
apiClient := sqlstore.NewClient(pp.serviceAdapter)
pp.bot = bot.New(pp.serviceAdapter, pp.config.GetConfiguration().BotUserID, pp.config, pp.telemetryClient)
scheduler := cluster.GetJobOnceScheduler(pp.serviceAdapter)
sqlStore, err := sqlstore.New(apiClient, scheduler)
if err != nil {
return errors.Wrapf(err, "failed creating the SQL store")
}
pp.playbookRunStore = sqlstore.NewPlaybookRunStore(apiClient, sqlStore)
pp.playbookStore = sqlstore.NewPlaybookStore(apiClient, sqlStore)
statsStore := sqlstore.NewStatsStore(apiClient, sqlStore)
pp.userInfoStore = sqlstore.NewUserInfoStore(sqlStore)
channelActionStore := sqlstore.NewChannelActionStore(apiClient, sqlStore)
categoryStore := sqlstore.NewCategoryStore(apiClient, sqlStore)
pp.handler = api.NewHandler(pp.config)
pp.playbookService = app.NewPlaybookService(pp.playbookStore, pp.bot, pp.telemetryClient, pp.serviceAdapter, pp.metricsService)
keywordsThreadIgnorer := app.NewKeywordsThreadIgnorer()
pp.channelActionService = app.NewChannelActionsService(pp.serviceAdapter, pp.bot, pp.config, channelActionStore, pp.playbookService, keywordsThreadIgnorer, pp.telemetryClient)
pp.categoryService = app.NewCategoryService(categoryStore, pp.serviceAdapter, pp.telemetryClient)
pp.licenseChecker = enterprise.NewLicenseChecker(pp.serviceAdapter)
pp.playbookRunService = app.NewPlaybookRunService(
pp.playbookRunStore,
pp.bot,
pp.config,
scheduler,
pp.telemetryClient,
pp.telemetryClient,
pp.serviceAdapter,
pp.playbookService,
pp.channelActionService,
pp.licenseChecker,
pp.metricsService,
)
if err = scheduler.SetCallback(pp.playbookRunService.HandleReminder); err != nil {
logrus.WithError(err).Error("JobOnceScheduler could not add the playbookRunService's HandleReminder")
}
if err = scheduler.Start(); err != nil {
logrus.WithError(err).Error("JobOnceScheduler could not start")
}
// Migrations use the scheduler, so they have to be run after playbookRunService and scheduler have started
mutex, err := cluster.NewMutex(pp.serviceAdapter, "IR_dbMutex")
if err != nil {
return errors.Wrapf(err, "failed creating cluster mutex")
}
mutex.Lock()
if err = sqlStore.RunMigrations(); err != nil {
mutex.Unlock()
return errors.Wrapf(err, "failed to run migrations")
}
mutex.Unlock()
pp.permissions = app.NewPermissionsService(
pp.playbookService,
pp.playbookRunService,
pp.serviceAdapter,
pp.config,
pp.licenseChecker,
)
// register collections and topics.
// TODO bump the minimum server version
if err = pp.serviceAdapter.RegisterCollectionAndTopic(CollectionTypeRun, TopicTypeStatus); err != nil {
logrus.WithError(err).WithField("collection_type", CollectionTypeRun).WithField("topic_type", TopicTypeStatus).Warnf("failed to register collection and topic")
}
if err = pp.serviceAdapter.RegisterCollectionAndTopic(CollectionTypeRun, TopicTypeTask); err != nil {
logrus.WithError(err).WithField("collection_type", CollectionTypeRun).WithField("topic_type", TopicTypeTask).Warnf("failed to register collection and topic")
}
api.NewGraphQLHandler(
pp.handler.APIRouter,
pp.playbookService,
pp.playbookRunService,
pp.categoryService,
pp.serviceAdapter,
pp.config,
pp.permissions,
pp.playbookStore,
pp.licenseChecker,
)
api.NewPlaybookHandler(
pp.handler.APIRouter,
pp.playbookService,
pp.serviceAdapter,
pp.config,
pp.permissions,
)
api.NewPlaybookRunHandler(
pp.handler.APIRouter,
pp.playbookRunService,
pp.playbookService,
pp.permissions,
pp.licenseChecker,
pp.serviceAdapter,
pp.bot,
pp.config,
)
api.NewStatsHandler(
pp.handler.APIRouter,
pp.serviceAdapter,
statsStore,
pp.playbookService,
pp.permissions,
pp.licenseChecker,
)
api.NewBotHandler(
pp.handler.APIRouter,
pp.serviceAdapter, pp.bot,
pp.config,
pp.playbookRunService,
pp.userInfoStore,
)
api.NewTelemetryHandler(
pp.handler.APIRouter,
pp.playbookRunService,
pp.serviceAdapter,
pp.telemetryClient,
pp.playbookService,
pp.telemetryClient,
pp.telemetryClient,
pp.telemetryClient,
pp.permissions,
)
api.NewSignalHandler(
pp.handler.APIRouter,
pp.serviceAdapter,
pp.playbookRunService,
pp.playbookService,
keywordsThreadIgnorer,
)
api.NewSettingsHandler(
pp.handler.APIRouter,
pp.serviceAdapter,
pp.config,
)
api.NewActionsHandler(
pp.handler.APIRouter,
pp.channelActionService,
pp.serviceAdapter,
pp.permissions,
)
api.NewCategoryHandler(
pp.handler.APIRouter,
pp.serviceAdapter,
pp.categoryService,
pp.playbookService,
pp.playbookRunService,
)
isTestingEnabled := false
flag := pp.serviceAdapter.GetConfig().ServiceSettings.EnableTesting
if flag != nil {
isTestingEnabled = *flag
}
if err = command.RegisterCommands(pp.serviceAdapter.RegisterCommand, isTestingEnabled); err != nil {
return errors.Wrapf(err, "failed register commands")
}
if err := pp.hooksService.RegisterHooks(playbooksProductName, pp); err != nil {
return fmt.Errorf("failed to register hooks: %w", err)
}

View File

@@ -15,8 +15,7 @@ import (
)
func TestActionCreation(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
createNewChannel := func(t *testing.T, name string) *model.Channel {
@@ -201,8 +200,7 @@ func TestActionCreation(t *testing.T) {
}
func TestActionList(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
// Create three valid actions
@@ -294,8 +292,7 @@ func TestActionList(t *testing.T) {
}
func TestActionUpdate(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
// Create a valid action

View File

@@ -16,8 +16,7 @@ func TestTrialLicences(t *testing.T) {
// This test is flaky due to upstream connectivity issues.
t.Skip()
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("request trial license without permissions", func(t *testing.T) {

View File

@@ -11,8 +11,7 @@ import (
)
func TestAPI(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateClients()
t.Run("404", func(t *testing.T) {

View File

@@ -21,8 +21,7 @@ import (
)
func TestGraphQLPlaybooks(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("basic get", func(t *testing.T) {
@@ -206,8 +205,7 @@ func TestGraphQLPlaybooks(t *testing.T) {
}
func TestGraphQLUpdatePlaybookFails(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("update playbook fails because size constraints.", func(t *testing.T) {
@@ -370,8 +368,7 @@ func TestGraphQLUpdatePlaybookFails(t *testing.T) {
}
func TestUpdatePlaybookFavorite(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("favorite", func(t *testing.T) {
@@ -493,8 +490,7 @@ func gqlTestPlaybookUpdate(e *TestEnvironment, t *testing.T, playbookID string,
}
func TestGraphQLPlaybooksMetrics(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("metrics get", func(t *testing.T) {

View File

@@ -20,8 +20,7 @@ import (
)
func TestGraphQLRunList(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("list by participantOrFollower", func(t *testing.T) {
@@ -206,8 +205,7 @@ func TestGraphQLRunList(t *testing.T) {
}
func TestGraphQLChangeRunParticipants(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
user3, _, err := e.ServerAdminClient.CreateUser(&model.User{
@@ -669,8 +667,7 @@ func TestGraphQLChangeRunParticipants(t *testing.T) {
}
func TestGraphQLChangeRunOwner(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
// create a third user to test change owner
@@ -713,8 +710,7 @@ func TestGraphQLChangeRunOwner(t *testing.T) {
}
func TestSetRunFavorite(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
createRun := func() *client.PlaybookRun {
@@ -800,8 +796,7 @@ func TestSetRunFavorite(t *testing.T) {
}
func TestResolverFavorites(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
createRun := func() *client.PlaybookRun {
@@ -833,8 +828,7 @@ func TestResolverFavorites(t *testing.T) {
}
func TestResolverPlaybooks(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
createRun := func() *client.PlaybookRun {
@@ -860,8 +854,7 @@ func TestResolverPlaybooks(t *testing.T) {
}
func TestUpdateRun(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
createRun := func() *client.PlaybookRun {
@@ -977,8 +970,7 @@ func TestUpdateRun(t *testing.T) {
}
func TestUpdateRunTaskActions(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("task actions mutation create and update", func(t *testing.T) {
@@ -1071,8 +1063,7 @@ func TestUpdateRunTaskActions(t *testing.T) {
}
func TestBadGraphQLRequest(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
testRunsQuery := `

View File

@@ -22,8 +22,7 @@ import (
)
func TestPlaybooks(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateClients()
e.CreateBasicServer()
@@ -267,8 +266,7 @@ func TestPlaybooks(t *testing.T) {
}
func TestCreateInvalidPlaybook(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateClients()
e.CreateBasicServer()
@@ -369,8 +367,7 @@ func TestCreateInvalidPlaybook(t *testing.T) {
}
func TestPlaybooksRetrieval(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("get playbook", func(t *testing.T) {
@@ -387,8 +384,7 @@ func TestPlaybooksRetrieval(t *testing.T) {
}
func TestPlaybookUpdate(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("update playbook properties", func(t *testing.T) {
@@ -521,8 +517,7 @@ func TestPlaybookUpdate(t *testing.T) {
}
func TestPlaybookUpdateCrossTeam(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("update playbook properties not in team public playbook", func(t *testing.T) {
@@ -552,8 +547,7 @@ func TestPlaybookUpdateCrossTeam(t *testing.T) {
}
func TestPlaybooksSort(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateClients()
e.CreateBasicServer()
e.SetE20Licence()
@@ -795,8 +789,7 @@ func TestPlaybooksSort(t *testing.T) {
}
func TestPlaybooksPaging(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateClients()
e.CreateBasicServer()
e.SetE20Licence()
@@ -935,8 +928,7 @@ func getPlaybookIDsList(playbooks []client.Playbook) []string {
}
func TestPlaybooksPermissions(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("test no permissions to create", func(t *testing.T) {
@@ -1148,8 +1140,7 @@ func TestPlaybooksPermissions(t *testing.T) {
}
func TestPlaybooksConversions(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("public to private conversion", func(t *testing.T) {
@@ -1208,8 +1199,7 @@ func TestPlaybooksConversions(t *testing.T) {
}
func TestPlaybooksImportExport(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateClients()
e.CreateBasicServer()
e.CreateBasicPublicPlaybook()
@@ -1237,8 +1227,7 @@ func TestPlaybooksImportExport(t *testing.T) {
}
func TestPlaybooksDuplicate(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateClients()
e.CreateBasicServer()
e.SetE20Licence()
@@ -1259,8 +1248,7 @@ func TestPlaybooksDuplicate(t *testing.T) {
}
func TestAddPostToTimeline(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
dialogRequest := model.SubmitDialogRequest{
@@ -1307,8 +1295,7 @@ func TestAddPostToTimeline(t *testing.T) {
}
func TestPlaybookStats(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateClients()
e.CreateBasicServer()
e.SetE20Licence()
@@ -1343,8 +1330,7 @@ func TestPlaybookStats(t *testing.T) {
}
func TestPlaybookGetAutoFollows(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
p1ID, err := e.PlaybooksAdminClient.Playbooks.Create(context.Background(), client.PlaybookCreateOptions{
@@ -1450,8 +1436,7 @@ func TestPlaybookGetAutoFollows(t *testing.T) {
}
func TestPlaybookChecklistCleanup(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("update playbook", func(t *testing.T) {

View File

@@ -19,8 +19,7 @@ import (
)
func TestRunCreation(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
incompletePlaybookID, err := e.PlaybooksAdminClient.Playbooks.Create(context.Background(), client.PlaybookCreateOptions{
@@ -314,8 +313,7 @@ func TestRunCreation(t *testing.T) {
}
func TestCreateRunInExistingChannel(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
// create playbook
@@ -410,8 +408,7 @@ func TestCreateRunInExistingChannel(t *testing.T) {
}
func TestCreateInvalidRuns(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("fails if description is longer than 4096", func(t *testing.T) {
@@ -428,8 +425,7 @@ func TestCreateInvalidRuns(t *testing.T) {
}
func TestRunRetrieval(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("by channel id", func(t *testing.T) {
@@ -510,8 +506,7 @@ func TestRunRetrieval(t *testing.T) {
}
func TestRunPostStatusUpdate(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("post an update", func(t *testing.T) {
@@ -571,8 +566,7 @@ func TestRunPostStatusUpdate(t *testing.T) {
}
func TestChecklistManagement(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
createNewRunWithNoChecklists := func(t *testing.T) *client.PlaybookRun {
@@ -1188,8 +1182,7 @@ func TestChecklistManagement(t *testing.T) {
}
func TestChecklisFailTooLarge(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("checklist creation - failure: too large checklist", func(t *testing.T) {
@@ -1213,8 +1206,7 @@ func TestChecklisFailTooLarge(t *testing.T) {
}
func TestRunGetStatusUpdates(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("public - get no updates", func(t *testing.T) {
@@ -1343,8 +1335,7 @@ func TestRunGetStatusUpdates(t *testing.T) {
}
func TestRequestUpdate(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("private - no viewer access ", func(t *testing.T) {
@@ -1437,8 +1428,7 @@ func TestRequestUpdate(t *testing.T) {
}
func TestReminderReset(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("reminder reset - timeline event created", func(t *testing.T) {
@@ -1485,8 +1475,7 @@ func TestReminderReset(t *testing.T) {
}
func TestChecklisItem_SetAssignee(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
addSimpleChecklistToTun := func(t *testing.T, runID string) *client.PlaybookRun {
@@ -1597,8 +1586,7 @@ func TestChecklisItem_SetAssignee(t *testing.T) {
}
func TestChecklisItem_SetCommand(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
run, err := e.PlaybooksClient.PlaybookRuns.Create(context.Background(), client.PlaybookRunCreateOptions{
@@ -1699,8 +1687,7 @@ func TestChecklisItem_SetCommand(t *testing.T) {
}
func TestGetOwners(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
ownerFromUser := func(u *model.User) client.OwnerInfo {

View File

@@ -14,8 +14,7 @@ import (
)
func TestSettings(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("get settings", func(t *testing.T) {

View File

@@ -16,8 +16,7 @@ import (
)
func TestGetSiteStats(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("get sites stats", func(t *testing.T) {
@@ -50,8 +49,7 @@ func TestGetSiteStats(t *testing.T) {
}
func TestPlaybookKeyMetricsStats(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("3 runs with published metrics, 2 runs without publishing", func(t *testing.T) {

View File

@@ -11,8 +11,7 @@ import (
)
func TestCreateEvent(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
t.Run("create an event with bad type fails", func(t *testing.T) {

View File

@@ -97,7 +97,7 @@ func getEnvWithDefault(name, defaultValue string) string {
return defaultValue
}
func Setup(t *testing.T) (*TestEnvironment, func()) {
func Setup(t *testing.T) *TestEnvironment {
// Ignore any locally defined SiteURL as we intend to host our own.
os.Unsetenv("MM_SERVICESETTINGS_SITEURL")
os.Unsetenv("MM_SERVICESETTINGS_LISTENADDRESS")
@@ -126,11 +126,6 @@ func Setup(t *testing.T) (*TestEnvironment, func()) {
config.LogSettings.EnableFile = model.NewBool(false)
config.LogSettings.ConsoleLevel = model.NewString("INFO")
// disable Boards through the feature flag
boardsProductEnvValue := os.Getenv("MM_FEATUREFLAGS_BoardsProduct")
os.Unsetenv("MM_FEATUREFLAGS_BoardsProduct")
config.FeatureFlags.BoardsProduct = false
// override config with e2etest.config.json if it exists
textConfig, err := os.ReadFile("./e2etest.config.json")
if err == nil {
@@ -169,10 +164,6 @@ func Setup(t *testing.T) (*TestEnvironment, func()) {
ap := sapp.New(sapp.ServerConnector(server.Channels()))
teardown := func() {
os.Setenv("MM_FEATUREFLAGS_BoardsProduct", boardsProductEnvValue)
}
return &TestEnvironment{
T: t,
Srv: server,
@@ -184,7 +175,7 @@ func Setup(t *testing.T) (*TestEnvironment, func()) {
},
},
logger: testLogger,
}, teardown
}
}
func (e *TestEnvironment) CreateClients() {
@@ -478,8 +469,7 @@ func (e *TestEnvironment) CreateBasic() {
// TestTestFramework If this is failing you know the break is not exclusively in your test.
func TestTestFramework(t *testing.T) {
e, teardown := Setup(t)
defer teardown()
e := Setup(t)
e.CreateBasic()
}

View File

@@ -85,7 +85,7 @@ function getSubpath(siteURL: string): string {
return url.pathname.replace(/\/+$/, '')
}
const TELEMETRY_RUDDER_KEY = 'placeholder_rudder_key'
const TELEMETRY_RUDDER_KEY = 'placeholder_boards_rudder_key'
const TELEMETRY_RUDDER_DATAPLANE_URL = 'placeholder_rudder_dataplane_url'
const TELEMETRY_OPTIONS = {
context: {

View File

@@ -2497,7 +2497,11 @@ const AdminDefinition = {
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.NOTIFICATIONS)),
it.stateIsFalse('EmailSettings.SendEmailNotifications'),
),
validate: validators.isRequired(t('admin.environment.notifications.feedbackEmail.required'), '"Notification From Address" is required'),
// MM-50952
// If the setting is hidden, then it is not being set in state so there is
// nothing to validate, and validation would fail anyways and prevent saving
validate: it.configIsFalse('ExperimentalSettings', 'RestrictSystemAdmin') && validators.isRequired(t('admin.environment.notifications.feedbackEmail.required'), '"Notification From Address" is required'),
},
{
type: Constants.SettingsTypes.TYPE_TEXT,

View File

@@ -5,16 +5,12 @@ import React from 'react';
import {FormattedMessage} from 'react-intl';
import {trackEvent} from 'actions/telemetry_actions';
import {useOpenCloudZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import ExternalLink from 'components/external_link';
type Props = {
cancelAccountLink: any;
}
const CancelSubscription = (props: Props) => {
const {
cancelAccountLink,
} = props;
const CancelSubscription = () => {
const description = `I am requesting that workspace "${window.location.host}" be deleted`;
const [, contactSupportURL] = useOpenCloudZendeskSupportForm('Request workspace be deleted', description);
return (
<div className='cancelSubscriptionSection'>
@@ -33,7 +29,7 @@ const CancelSubscription = (props: Props) => {
</div>
<ExternalLink
location='cancel_subscription'
href={cancelAccountLink}
href={contactSupportURL}
className='cancelSubscriptionSection__contactUs'
onClick={() => trackEvent('cloud_admin', 'click_contact_us')}
>

View File

@@ -24,7 +24,6 @@ import AlertBanner from 'components/alert_banner';
import UpgradeLink from 'components/widgets/links/upgrade_link';
import './cloud_trial_banner.scss';
import {SalesInquiryIssue} from 'selectors/cloud';
export interface Props {
trialEndDate: number;
@@ -34,7 +33,7 @@ const CloudTrialBanner = ({trialEndDate}: Props): JSX.Element | null => {
const endDate = new Date(trialEndDate);
const DISMISSED_DAYS = 10;
const {formatMessage} = useIntl();
const openSalesLink = useOpenSalesLink(SalesInquiryIssue.UpgradeEnterprise);
const [openSalesLink] = useOpenSalesLink();
const dispatch = useDispatch();
const user = useSelector(getCurrentUser);
const storedDismissedEndDate = useSelector((state: GlobalState) => getPreference(state, Preferences.CLOUD_TRIAL_BANNER, CloudBanners.UPGRADE_FROM_TRIAL));

View File

@@ -10,21 +10,19 @@ import {CloudLinks, CloudProducts} from 'utils/constants';
import PrivateCloudSvg from 'components/common/svg_images_components/private_cloud_svg';
import CloudTrialSvg from 'components/common/svg_images_components/cloud_trial_svg';
import {TelemetryProps} from 'components/common/hooks/useOpenPricingModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import ExternalLink from 'components/external_link';
type Props = {
contactSalesLink: any;
isFreeTrial: boolean;
trialQuestionsLink: any;
subscriptionPlan: string | undefined;
onUpgradeMattermostCloud: (telemetryProps?: TelemetryProps | undefined) => void;
}
const ContactSalesCard = (props: Props) => {
const [openSalesLink, contactSalesLink] = useOpenSalesLink();
const {
contactSalesLink,
isFreeTrial,
trialQuestionsLink,
subscriptionPlan,
onUpgradeMattermostCloud,
} = props;
@@ -145,7 +143,7 @@ const ContactSalesCard = (props: Props) => {
{(isFreeTrial || subscriptionPlan === CloudProducts.ENTERPRISE || isCloudLegacyPlan) &&
<ExternalLink
location='contact_sales_card'
href={isFreeTrial ? trialQuestionsLink : contactSalesLink}
href={contactSalesLink}
className='PrivateCloudCard__actionButton'
onClick={() => trackEvent('cloud_admin', 'click_contact_sales')}
>
@@ -163,7 +161,7 @@ const ContactSalesCard = (props: Props) => {
if (subscriptionPlan === CloudProducts.STARTER) {
onUpgradeMattermostCloud({trackingLocation: 'admin_console_subscription_card_upgrade_now_button'});
} else {
window.open(contactSalesLink, '_blank');
openSalesLink();
}
}}
className='PrivateCloudCard__actionButton'

View File

@@ -13,7 +13,6 @@ import FormattedAdminHeader from 'components/widgets/admin_console/formatted_adm
import CloudTrialBanner from 'components/admin_console/billing/billing_subscriptions/cloud_trial_banner';
import CloudFetchError from 'components/cloud_fetch_error';
import {getCloudContactUsLink, InquiryType, SalesInquiryIssue} from 'selectors/cloud';
import {
getSubscriptionProduct,
getCloudSubscription as selectCloudSubscription,
@@ -63,9 +62,6 @@ const BillingSubscriptions = () => {
const isCardExpired = isCustomerCardExpired(useSelector(selectCloudCustomer));
const contactSalesLink = useSelector(getCloudContactUsLink)(InquiryType.Sales);
const cancelAccountLink = useSelector(getCloudContactUsLink)(InquiryType.Sales, SalesInquiryIssue.CancelAccount);
const trialQuestionsLink = useSelector(getCloudContactUsLink)(InquiryType.Sales, SalesInquiryIssue.TrialQuestions);
const trialEndDate = subscription?.trial_end_at || 0;
const [showCreditCardBanner, setShowCreditCardBanner] = useState(true);
@@ -159,19 +155,12 @@ const BillingSubscriptions = () => {
<Limits/>
) : (
<ContactSalesCard
contactSalesLink={contactSalesLink}
isFreeTrial={isFreeTrial}
trialQuestionsLink={trialQuestionsLink}
subscriptionPlan={product?.sku}
onUpgradeMattermostCloud={openPricingModal}
/>
)}
{isAnnualProfessionalOrEnterprise && !isFreeTrial ?
<CancelSubscription
cancelAccountLink={cancelAccountLink}
/> :
<DeleteWorkspaceCTA/>
}
{isAnnualProfessionalOrEnterprise && !isFreeTrial ? <CancelSubscription/> : <DeleteWorkspaceCTA/>}
</>}
</div>
</div>

View File

@@ -177,7 +177,7 @@ describe('limits_reached_banner', () => {
const store = mockStore(state);
const spies = makeSpies();
const mockOpenSalesLink = jest.fn();
spies.useOpenSalesLink.mockReturnValue(mockOpenSalesLink);
spies.useOpenSalesLink.mockReturnValue([mockOpenSalesLink, '']);
spies.useGetUsageDeltas.mockReturnValue(someLimitReached);
renderWithIntl(<Provider store={store}><LimitReachedBanner product={free}/></Provider>);
screen.getByText(titleFree);

View File

@@ -5,8 +5,6 @@ import React from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {SalesInquiryIssue} from 'selectors/cloud';
import {CloudProducts} from 'utils/constants';
import {anyUsageDeltaExceededLimit} from 'utils/limits';
@@ -33,7 +31,7 @@ const LimitReachedBanner = (props: Props) => {
const hasDismissedBanner = useSelector(getHasDismissedSystemConsoleLimitReached);
const openSalesLink = useOpenSalesLink(props.product?.sku === CloudProducts.PROFESSIONAL ? SalesInquiryIssue.UpgradeEnterprise : undefined);
const [openSalesLink] = useOpenSalesLink();
const openPricingModal = useOpenPricingModal();
const saveBool = useSaveBool();
if (hasDismissedBanner || !someLimitExceeded || !props.product || (props.product.sku !== CloudProducts.STARTER)) {

View File

@@ -11,8 +11,6 @@ import {
getSubscriptionProduct,
} from 'mattermost-redux/selectors/entities/cloud';
import {SalesInquiryIssue} from 'selectors/cloud';
import {CloudProducts} from 'utils/constants';
import {asGBString, fallbackStarterLimits, hasSomeLimits} from 'utils/limits';
@@ -32,7 +30,7 @@ const Limits = (): JSX.Element | null => {
const subscriptionProduct = useSelector(getSubscriptionProduct);
const [cloudLimits, limitsLoaded] = useGetLimits();
const usage = useGetUsage();
const openSalesLink = useOpenSalesLink(SalesInquiryIssue.UpgradeEnterprise);
const [openSalesLink] = useOpenSalesLink();
const openPricingModal = useOpenPricingModal();
if (!subscriptionProduct || !limitsLoaded || !hasSomeLimits(cloudLimits)) {

View File

@@ -10,7 +10,6 @@ import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurch
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
import {SalesInquiryIssue} from 'selectors/cloud';
import {getSubscriptionProduct as selectSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {savePreferences} from 'mattermost-redux/actions/preferences';
@@ -79,7 +78,7 @@ const ToYearlyNudgeBannerDismissable = () => {
const ToYearlyNudgeBanner = () => {
const {formatMessage} = useIntl();
const openSalesLink = useOpenSalesLink(SalesInquiryIssue.AboutPurchasing);
const [openSalesLink] = useOpenSalesLink();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const product = useSelector(selectSubscriptionProduct);

View File

@@ -7,6 +7,7 @@ import {useDispatch, useSelector} from 'react-redux';
import IconMessage from 'components/purchase_modal/icon_message';
import FullScreenModal from 'components/widgets/modals/full_screen_modal';
import {useOpenCloudZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import {closeModal} from 'actions/views/modals';
import {isModalOpen} from 'selectors/views/modals';
@@ -14,9 +15,6 @@ import {GlobalState} from 'types/store';
import './result_modal.scss';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {InquiryType} from 'selectors/cloud';
type Props = {
onHide?: () => void;
icon: JSX.Element;
@@ -33,7 +31,7 @@ type Props = {
export default function ResultModal(props: Props) {
const dispatch = useDispatch();
const openContactUs = useOpenSalesLink(undefined, InquiryType.Technical);
const [openContactSupport] = useOpenCloudZendeskSupportForm('Delete workspace', '');
const isResultModalOpen = useSelector((state: GlobalState) =>
isModalOpen(state, props.identifier),
@@ -64,14 +62,13 @@ export default function ResultModal(props: Props) {
buttonHandler={props.primaryButtonHandler}
className={'success'}
formattedTertiaryButonText={
props.contactSupportButtonVisible ?
props.contactSupportButtonVisible ? (
<FormattedMessage
id={'admin.billing.deleteWorkspace.resultModal.ContactSupport'}
defaultMessage={'Contact Support'}
/> :
undefined
/>) : undefined
}
tertiaryButtonHandler={props.contactSupportButtonVisible ? openContactUs : undefined}
tertiaryButtonHandler={props.contactSupportButtonVisible ? openContactSupport : undefined}
/>
</div>
</FullScreenModal>

View File

@@ -17,7 +17,6 @@ describe('components/feature_discovery', () => {
<FeatureDiscovery
featureName='test'
minimumSKURequiredForFeature={LicenseSkus.Professional}
contactSalesLink='/sales'
titleID='translation.test.title'
titleDefault='Foo'
copyID='translation.test.copy'
@@ -46,7 +45,6 @@ describe('components/feature_discovery', () => {
<FeatureDiscovery
featureName='test'
minimumSKURequiredForFeature={LicenseSkus.Professional}
contactSalesLink='/sales'
titleID='translation.test.title'
titleDefault='Foo'
copyID='translation.test.copy'
@@ -76,7 +74,6 @@ describe('components/feature_discovery', () => {
<FeatureDiscovery
featureName='test'
minimumSKURequiredForFeature={LicenseSkus.Professional}
contactSalesLink='/sales'
titleID='translation.test.title'
titleDefault='Foo'
copyID='translation.test.copy'

View File

@@ -6,9 +6,6 @@ import {FormattedMessage} from 'react-intl';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import {AnalyticsRow} from '@mattermost/types/admin';
import {ClientLicense} from '@mattermost/types/config';
import {EmbargoedEntityTrialError} from 'components/admin_console/license_settings/trial_banner/trial_banner';
import AlertBanner from 'components/alert_banner';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
@@ -17,17 +14,22 @@ import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_lin
import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us';
import PurchaseModal from 'components/purchase_modal';
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
import ExternalLink from 'components/external_link';
import {ModalIdentifiers, TELEMETRY_CATEGORIES, AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants';
import {FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS} from 'utils/cloud_utils';
import * as Utils from 'utils/utils';
import {goToMattermostContactSalesForm} from 'utils/contact_support_sales';
import {trackEvent} from 'actions/telemetry_actions';
import {ModalData} from 'types/actions';
import {ClientLicense} from '@mattermost/types/config';
import {AnalyticsRow} from '@mattermost/types/admin';
import {CloudCustomer} from '@mattermost/types/cloud';
import './feature_discovery.scss';
import ExternalLink from 'components/external_link';
type Props = {
featureName: string;
@@ -56,7 +58,7 @@ type Props = {
hadPrevCloudTrial: boolean;
isSubscriptionLoaded: boolean;
isPaidSubscription: boolean;
contactSalesLink: string;
customer?: CloudCustomer;
}
type State = {
@@ -97,6 +99,16 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
});
}
contactSalesFunc = () => {
const {customer, isCloud} = this.props;
const customerEmail = customer?.email || '';
const firstName = customer?.contact_first_name || '';
const lastName = customer?.contact_last_name || '';
const companyName = customer?.name || '';
const utmMedium = isCloud ? 'in-product-cloud' : 'in-product';
goToMattermostContactSalesForm(firstName, lastName, companyName, customerEmail, 'mattermost', utmMedium);
}
renderPostTrialCta = () => {
const {
minimumSKURequiredForFeature,
@@ -110,7 +122,7 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
data-testid='featureDiscovery_primaryCallToAction'
onClick={() => {
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
window.open(LicenseLinks.CONTACT_SALES, '_blank');
this.contactSalesFunc();
}}
>
<FormattedMessage
@@ -161,7 +173,6 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
hadPrevCloudTrial,
isPaidSubscription,
minimumSKURequiredForFeature,
contactSalesLink,
} = this.props;
const canRequestCloudFreeTrial = isCloud && !isCloudTrial && !hadPrevCloudTrial && !isPaidSubscription;
@@ -217,11 +228,10 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
onClick={() => {
if (isCloud) {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
window.open(contactSalesLink, '_blank');
} else {
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
window.open(LicenseLinks.CONTACT_SALES, '_blank');
}
this.contactSalesFunc();
}}
>
<FormattedMessage

View File

@@ -7,11 +7,9 @@ import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {getPrevTrialLicense} from 'mattermost-redux/actions/admin';
import {getCloudSubscription} from 'mattermost-redux/actions/cloud';
import {Action, GenericAction} from 'mattermost-redux/types/actions';
import {checkHadPriorTrial} from 'mattermost-redux/selectors/entities/cloud';
import {checkHadPriorTrial, getCloudCustomer} from 'mattermost-redux/selectors/entities/cloud';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getCloudContactUsLink, InquiryType} from 'selectors/cloud';
import {ModalData} from 'types/actions';
import {GlobalState} from 'types/store';
@@ -30,7 +28,7 @@ function mapStateToProps(state: GlobalState) {
const isCloud = isCloudLicense(license);
const hasPriorTrial = checkHadPriorTrial(state);
const isCloudTrial = subscription?.is_free_trial === 'true';
const contactSalesLink = getCloudContactUsLink(state)(InquiryType.Sales);
const customer = getCloudCustomer(state);
return {
stats: state.entities.admin.analytics,
prevTrialLicense: state.entities.admin.prevTrialLicense,
@@ -39,7 +37,7 @@ function mapStateToProps(state: GlobalState) {
isSubscriptionLoaded: subscription !== undefined && subscription !== null,
hadPrevCloudTrial: hasPriorTrial,
isPaidSubscription: isCloud && license?.SkuShortName !== LicenseSkus.Starter && !isCloudTrial,
contactSalesLink,
customer,
};
}

View File

@@ -2,13 +2,46 @@
// See LICENSE.txt for license information.
import React from 'react';
import {Provider} from 'react-redux';
import {LicenseSkus} from 'utils/constants';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import EnterpriseEditionRightPanel, {EnterpriseEditionProps} from './enterprise_edition_right_panel';
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
config: {
CWSURL: '',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_user'},
},
},
preferences: {
myPreferences: {},
},
cloud: {},
},
};
describe('components/admin_console/license_settings/enterprise_edition/enterprise_edition_right_panel', () => {
const license = {
IsLicensed: 'true',
@@ -28,8 +61,11 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
} as EnterpriseEditionProps;
test('should render for no Gov no Trial no Enterprise', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel {...props}/>,
<Provider store={store}>
<EnterpriseEditionRightPanel {...props}/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Upgrade to the Enterprise Plan');
@@ -43,11 +79,14 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
});
test('should render for Gov no Trial no Enterprise', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel
license={{...props.license, IsGovSku: 'true'}}
isTrialLicense={props.isTrialLicense}
/>,
<Provider store={store}>
<EnterpriseEditionRightPanel
license={{...props.license, IsGovSku: 'true'}}
isTrialLicense={props.isTrialLicense}
/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Upgrade to the Enterprise Gov Plan');
@@ -61,11 +100,14 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
});
test('should render for Enterprise no Trial', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel
license={{...props.license, SkuShortName: LicenseSkus.Enterprise}}
isTrialLicense={props.isTrialLicense}
/>,
<Provider store={store}>
<EnterpriseEditionRightPanel
license={{...props.license, SkuShortName: LicenseSkus.Enterprise}}
isTrialLicense={props.isTrialLicense}
/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Need to increase your headcount?');
@@ -73,11 +115,14 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
});
test('should render for E20 no Trial', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel
license={{...props.license, SkuShortName: LicenseSkus.E20}}
isTrialLicense={props.isTrialLicense}
/>,
<Provider store={store}>
<EnterpriseEditionRightPanel
license={{...props.license, SkuShortName: LicenseSkus.E20}}
isTrialLicense={props.isTrialLicense}
/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Need to increase your headcount?');
@@ -85,11 +130,14 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
});
test('should render for Trial no Gov', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel
license={props.license}
isTrialLicense={true}
/>,
<Provider store={store}>
<EnterpriseEditionRightPanel
license={props.license}
isTrialLicense={true}
/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Purchase the Enterprise Plan');
@@ -97,11 +145,14 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
});
test('should render for Trial Gov', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel
license={{...props.license, IsGovSku: 'true'}}
isTrialLicense={true}
/>,
<Provider store={store}>
<EnterpriseEditionRightPanel
license={{...props.license, IsGovSku: 'true'}}
isTrialLicense={true}
/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Purchase the Enterprise Gov Plan');

View File

@@ -12,6 +12,37 @@ import mockStore from 'tests/test_store';
import RenewalLicenseCard from './renew_license_card';
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
config: {
CWSURL: '',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_user'},
},
},
preferences: {
myPreferences: {},
},
cloud: {},
},
};
const actImmediate = (wrapper: ReactWrapper) =>
act(
() =>
@@ -47,7 +78,7 @@ describe('components/RenewalLicenseCard', () => {
});
});
getRenewalLinkSpy.mockImplementation(() => promise);
const store = mockStore({});
const store = mockStore(initialState);
const wrapper = mountWithIntl(<Provider store={store}><RenewalLicenseCard {...props}/></Provider>);
// wait for the promise to resolve and component to update
@@ -64,7 +95,7 @@ describe('components/RenewalLicenseCard', () => {
reject(new Error('License cannot be renewed from portal'));
});
getRenewalLinkSpy.mockImplementation(() => promise);
const store = mockStore({});
const store = mockStore(initialState);
const wrapper = mountWithIntl(<Provider store={store}><RenewalLicenseCard {...props}/></Provider>);
// wait for the promise to resolve and component to update

View File

@@ -18,8 +18,9 @@ import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {GlobalState} from '@mattermost/types/store';
import {CloudLinks, ConsolePages, DocLinks, LicenseLinks} from 'utils/constants';
import {CloudLinks, ConsolePages, DocLinks} from 'utils/constants';
import {daysToLicenseExpire, isEnterpriseOrE20License, getIsStarterLicense} from '../../../utils/license_utils';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
export type DataModel = {
[key: string]: {
@@ -84,8 +85,10 @@ const useMetricsData = () => {
const isEnterpriseLicense = isEnterpriseOrE20License(license);
const isStarterLicense = getIsStarterLicense(license);
const [, contactSalesLink] = useOpenSalesLink();
const trialOrEnterpriseCtaConfig = {
configUrl: canStartTrial ? ConsolePages.LICENSE : LicenseLinks.CONTACT_SALES,
configUrl: canStartTrial ? ConsolePages.LICENSE : contactSalesLink,
configText: canStartTrial ? formatMessage({id: 'admin.reporting.workspace_optimization.cta.startTrial', defaultMessage: 'Start trial'}) : formatMessage({id: 'admin.reporting.workspace_optimization.cta.upgradeLicense', defaultMessage: 'Contact sales'}),
};

View File

@@ -6,8 +6,9 @@ import React from 'react';
import {FormattedMessage} from 'react-intl';
import {trackEvent} from 'actions/telemetry_actions';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import './contact_us.scss';
import {LicenseLinks} from '../../../utils/constants';
export interface Props {
buttonTextElement?: JSX.Element;
@@ -16,10 +17,12 @@ export interface Props {
}
const ContactUsButton: React.FC<Props> = (props: Props) => {
const [openContactSales] = useOpenSalesLink();
const handleContactUsLinkClick = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
trackEvent('admin', props.eventID || 'in_trial_contact_sales');
window.open(LicenseLinks.CONTACT_SALES, '_blank');
openContactSales();
};
return (

View File

@@ -15,7 +15,8 @@ import {savePreferences} from 'mattermost-redux/actions/preferences';
import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences';
import {PreferenceType} from '@mattermost/types/preferences';
import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck';
import {LicenseLinks, StatTypes, Preferences, AnnouncementBarTypes} from 'utils/constants';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {StatTypes, Preferences, AnnouncementBarTypes} from 'utils/constants';
import './overage_users_banner.scss';
@@ -34,6 +35,7 @@ const adminHasDismissed = ({preferenceName, overagePreferences, isWarningBanner}
};
const OverageUsersBanner = () => {
const [openContactSales] = useOpenSalesLink();
const dispatch = useDispatch();
const stats = useSelector((state: GlobalState) => state.entities.admin.analytics) || {};
const isAdmin = useSelector(isCurrentUserSystemAdmin);
@@ -90,7 +92,7 @@ const OverageUsersBanner = () => {
const handleContactSalesClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
trackEventFn('Contact Sales');
window.open(LicenseLinks.CONTACT_SALES, '_blank');
openContactSales();
};
const handleClick = isExpandable ? handleUpdateSeatsSelfServeClick : handleContactSalesClick;

View File

@@ -7,7 +7,7 @@ import {fireEvent, screen} from '@testing-library/react';
import {DeepPartial} from '@mattermost/types/utilities';
import {GlobalState} from 'types/store';
import {General} from 'mattermost-redux/constants';
import {LicenseLinks, OverActiveUserLimits, Preferences, StatTypes} from 'utils/constants';
import {OverActiveUserLimits, Preferences, StatTypes} from 'utils/constants';
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {trackEvent} from 'actions/telemetry_actions';
@@ -249,7 +249,10 @@ describe('components/overage_users_banner', () => {
fireEvent.click(screen.getByText(contactSalesTextLink));
expect(windowSpy).toBeCalledTimes(1);
expect(windowSpy).toBeCalledWith(LicenseLinks.CONTACT_SALES, '_blank');
// only the email is encoded and other params are empty. See logic for useOpenSalesLink hook
const salesLinkWithEncodedParams = 'https://mattermost.com/contact-sales/?qk=&qp=&qw=&qx=dGVzdEBtYXR0ZXJtb3N0LmNvbQ==&utm_source=mattermost&utm_medium=in-product';
expect(windowSpy).toBeCalledWith(salesLinkWithEncodedParams, '_blank');
expect(trackEvent).toBeCalledTimes(1);
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_warning', {
cta: 'Contact Sales',
@@ -368,7 +371,10 @@ describe('components/overage_users_banner', () => {
fireEvent.click(screen.getByText(contactSalesTextLink));
expect(windowSpy).toBeCalledTimes(1);
expect(windowSpy).toBeCalledWith(LicenseLinks.CONTACT_SALES, '_blank');
// only the email is encoded and other params are empty. See logic for useOpenSalesLink hook
const salesLinkWithEncodedParams = 'https://mattermost.com/contact-sales/?qk=&qp=&qw=&qx=dGVzdEBtYXR0ZXJtb3N0LmNvbQ==&utm_source=mattermost&utm_medium=in-product';
expect(windowSpy).toBeCalledWith(salesLinkWithEncodedParams, '_blank');
expect(trackEvent).toBeCalledTimes(1);
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_error', {
cta: 'Contact Sales',

View File

@@ -3,13 +3,46 @@
import React from 'react';
import {ReactWrapper} from 'enzyme';
import {Provider} from 'react-redux';
import {act} from 'react-dom/test-utils';
import {Client4} from 'mattermost-redux/client';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import RenewalLink from './renewal_link';
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
config: {
CWSURL: '',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_user'},
},
},
preferences: {
myPreferences: {},
},
cloud: {},
},
};
const actImmediate = (wrapper: ReactWrapper) =>
act(
() =>
@@ -40,7 +73,8 @@ describe('components/RenewalLink', () => {
});
});
getRenewalLinkSpy.mockImplementation(() => promise);
const wrapper = mountWithIntl(<RenewalLink {...props}/>);
const store = mockStore(initialState);
const wrapper = mountWithIntl(<Provider store={store}><RenewalLink {...props}/></Provider>);
// wait for the promise to resolve and component to update
await actImmediate(wrapper);
@@ -54,7 +88,8 @@ describe('components/RenewalLink', () => {
reject(new Error('License cannot be renewed from portal'));
});
getRenewalLinkSpy.mockImplementation(() => promise);
const wrapper = mountWithIntl(<RenewalLink {...props}/>);
const store = mockStore(initialState);
const wrapper = mountWithIntl(<Provider store={store}><RenewalLink {...props}/></Provider>);
// wait for the promise to resolve and component to update
await actImmediate(wrapper);

View File

@@ -11,9 +11,9 @@ import {trackEvent} from 'actions/telemetry_actions';
import {ModalData} from 'types/actions';
import {
LicenseLinks,
ModalIdentifiers,
} from 'utils/constants';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import NoInternetConnection from '../no_internet_connection/no_internet_connection';
@@ -31,6 +31,9 @@ export interface RenewalLinkProps {
const RenewalLink = (props: RenewalLinkProps) => {
const [renewalLink, setRenewalLink] = useState('');
const [manualInterventionRequired, setManualInterventionRequired] = useState(false);
const [openContactSales] = useOpenSalesLink();
useEffect(() => {
Client4.getRenewalLink().then(({renewal_link: renewalLinkParam}) => {
try {
@@ -55,7 +58,7 @@ const RenewalLink = (props: RenewalLinkProps) => {
}
window.open(renewalLink, '_blank');
} else if (manualInterventionRequired) {
window.open(LicenseLinks.CONTACT_SALES, '_blank');
openContactSales();
} else {
showConnectionErrorModal();
}

View File

@@ -0,0 +1,37 @@
.shipping-address-section {
display: flex;
align-content: flex-start;
padding-bottom: 24px;
font-weight: normal;
button.no-style {
padding-left: 0;
border: none;
background: transparent;
outline: unset;
text-align: left;
&:focus {
outline: unset;
}
}
#address-same-than-billing-address {
width: 17px;
height: 17px;
flex-shrink: 0;
}
.Form-checkbox-label {
padding-left: 12px;
cursor: default;
font-family: 'Open Sans', sans-serif;
vertical-align: middle;
}
.billing_address_btn_text {
color: var(--center-channel-color);
font-family: 'Open Sans', sans-serif;
font-weight: bold;
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import './choose_different_shipping.scss';
interface Props {
shippingIsSame: boolean;
setShippingIsSame: (different: boolean) => void;
}
export default function ChooseDifferentShipping(props: Props) {
const intl = useIntl();
const toggle = () => props.setShippingIsSame(!props.shippingIsSame);
return (
<div className='shipping-address-section'>
<input
id='address-same-than-billing-address'
className='Form-checkbox-input'
name='terms'
type='checkbox'
checked={props.shippingIsSame}
onChange={toggle}
/>
<span className='Form-checkbox-label'>
<button
onClick={toggle}
type='button'
className='no-style'
>
<span className='billing_address_btn_text'>
{intl.formatMessage({
id: 'admin.billing.subscription.complianceScreenShippingSameAsBilling',
defaultMessage:
'My shipping address is the same as my billing address',
})}
</span>
</button>
</span>
</div>
);
}

View File

@@ -13,9 +13,8 @@ import PaymentFailedSvg from 'components/common/svg_images_components/payment_fa
import IconMessage from 'components/purchase_modal/icon_message';
import FullScreenModal from 'components/widgets/modals/full_screen_modal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {useOpenCloudZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import {InquiryType} from 'selectors/cloud';
import {closeModal} from 'actions/views/modals';
import {ModalIdentifiers} from 'utils/constants';
import {isModalOpen} from 'selectors/views/modals';
@@ -31,7 +30,8 @@ type Props = {
function ErrorModal(props: Props) {
const dispatch = useDispatch();
const subscriptionProduct = useSelector(getSubscriptionProduct);
const openContactUs = useOpenSalesLink(undefined, InquiryType.Technical);
const [openContactSupport] = useOpenCloudZendeskSupportForm('Cloud Subscription', '');
const isSuccessModalOpen = useSelector((state: GlobalState) =>
isModalOpen(state, ModalIdentifiers.ERROR_MODAL),
@@ -97,7 +97,7 @@ function ErrorModal(props: Props) {
}
/>
}
tertiaryButtonHandler={openContactUs}
tertiaryButtonHandler={openContactSupport}
buttonHandler={onBackButtonPress}
className={'success'}
/>

View File

@@ -3,11 +3,35 @@
import {useSelector} from 'react-redux';
import {getCloudContactUsLink, InquiryType, SalesInquiryIssue} from 'selectors/cloud';
import {getCloudCustomer, isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {buildMMURL, goToMattermostContactSalesForm} from 'utils/contact_support_sales';
import {LicenseLinks} from 'utils/constants';
export default function useOpenSalesLink(issue?: SalesInquiryIssue, inquireType: InquiryType = InquiryType.Sales) {
const contactSalesLink = useSelector(getCloudContactUsLink)(inquireType, issue);
export default function useOpenSalesLink(): [() => void, string] {
const isCloud = useSelector(isCurrentLicenseCloud);
const customer = useSelector(getCloudCustomer);
const currentUser = useSelector(getCurrentUser);
let customerEmail = '';
let firstName = '';
let lastName = '';
let companyName = '';
const utmSource = 'mattermost';
let utmMedium = 'in-product';
return () => window.open(contactSalesLink, '_blank');
if (isCloud && customer) {
customerEmail = customer.email || '';
firstName = customer.contact_first_name || '';
lastName = customer.contact_last_name || '';
companyName = customer.name || '';
utmMedium = 'in-product-cloud';
} else {
customerEmail = currentUser.email || '';
}
const contactSalesLink = buildMMURL(LicenseLinks.CONTACT_SALES, firstName, lastName, companyName, customerEmail, utmSource, utmMedium);
const goToSalesLinkFunc = () => {
goToMattermostContactSalesForm(firstName, lastName, companyName, customerEmail, utmSource, utmMedium);
};
return [goToSalesLinkFunc, contactSalesLink];
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useSelector} from 'react-redux';
import {getCloudCustomer} from 'mattermost-redux/selectors/entities/cloud';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {getCloudSupportLink, getSelfHostedSupportLink, goToCloudSupportForm, goToSelfHostedSupportForm} from 'utils/contact_support_sales';
export function useOpenCloudZendeskSupportForm(subject: string, description: string): [() => void, string] {
const customer = useSelector(getCloudCustomer);
const customerEmail = customer?.email || '';
const url = getCloudSupportLink(customerEmail, subject, description, window.location.host);
return [() => goToCloudSupportForm(customerEmail, subject, description, window.location.host), url];
}
export function useOpenSelfHostedZendeskSupportForm(subject: string): [() => void, string] {
const currentUser = useSelector(getCurrentUser);
const customerEmail = currentUser.email || '';
const url = getSelfHostedSupportLink(customerEmail, subject);
return [() => goToSelfHostedSupportForm(customerEmail, subject), url];
}

View File

@@ -1,5 +1,7 @@
$dropdown_input_index: 999999;
.DropdownInput {
z-index: 999999;
z-index: $dropdown_input_index;
&.Input_container {
margin-top: 20px;
@@ -37,7 +39,7 @@
}
.DropdownInput__option > div {
z-index: 999999;
z-index: $dropdown_input_index;
padding: 10px 24px;
cursor: pointer;
line-height: 16px;
@@ -51,3 +53,33 @@
.DropdownInput__option.focused > div {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
.second-dropdown-sibling-wrapper {
.DropdownInput {
z-index: $dropdown_input_index - 1;
}
.DropdownInput__option > div {
z-index: $dropdown_input_index - 1;
}
}
.third-dropdown-sibling-wrapper {
.DropdownInput {
z-index: $dropdown_input_index - 2;
}
.DropdownInput__option > div {
z-index: $dropdown_input_index - 2;
}
}
.fourth-dropdown-sibling-wrapper {
.DropdownInput {
z-index: $dropdown_input_index - 3;
}
.DropdownInput__option > div {
z-index: $dropdown_input_index - 3;
}
}

View File

@@ -61,25 +61,27 @@ const AddressForm = (props: AddressFormProps) => {
{...props.title}
/>
</div>
<DropdownInput
onChange={handleCountryChange}
value={
props.address.country ? {value: props.address.country, label: props.address.country} : undefined
}
options={COUNTRIES.map((country) => ({
value: country.name,
label: country.name,
}))}
legend={formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
placeholder={formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
name={'billing_dropdown'}
/>
<div className='third-dropdown-sibling-wrapper'>
<DropdownInput
onChange={handleCountryChange}
value={
props.address.country ? {value: props.address.country, label: props.address.country} : undefined
}
options={COUNTRIES.map((country) => ({
value: country.name,
label: country.name,
}))}
legend={formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
placeholder={formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
name={'billing_dropdown'}
/>
</div>
<div className='form-row'>
<Input
name='address'
@@ -122,7 +124,7 @@ const AddressForm = (props: AddressFormProps) => {
/>
</div>
<div className='form-row'>
<div className='form-row-third-1 selector'>
<div className='form-row-third-1 selector fourth-dropdown-sibling-wrapper'>
<StateSelector
country={props.address.country}
state={props.address.state}

View File

@@ -12,7 +12,6 @@
.form-row-third-1 {
.DropdownInput {
z-index: 99999;
margin-top: 0;
}
@@ -37,7 +36,6 @@
.DropdownInput {
position: relative;
z-index: 999999;
height: 36px;
margin-bottom: 24px;
font-weight: normal;

View File

@@ -251,7 +251,7 @@ export default class PaymentForm extends React.PureComponent<Props, State> {
/>
</div>
<div className='form-row'>
<div className='form-row-third-1 selector'>
<div className='form-row-third-1 selector second-dropdown-sibling-wrapper'>
<StateSelector
country={this.state.country}
state={this.state.state}

View File

@@ -155,7 +155,7 @@ function PostPriorityPicker({
}
}
const feedbackLink = postAcknowledgementsEnabled ? 'https://forms.gle/noA8Azg7RdaBZtMB6' : 'https://forms.gle/XRb63s3KZqpLNyqr9';
const feedbackLink = postAcknowledgementsEnabled ? 'https://forms.gle/noA8Azg7RdaBZtMB6' : 'https://forms.gle/mMcRFQzyKAo9Sv49A';
return (
<Picker

View File

@@ -11,8 +11,7 @@ import {trackEvent} from 'actions/telemetry_actions';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {LicenseLinks, TELEMETRY_CATEGORIES} from 'utils/constants';
import {SalesInquiryIssue} from 'selectors/cloud';
import {TELEMETRY_CATEGORIES} from 'utils/constants';
const StyledA = styled.a`
color: var(--denim-button-bg);
@@ -27,11 +26,7 @@ text-align: center;
function ContactSalesCTA() {
const {formatMessage} = useIntl();
const openSalesLink = useOpenSalesLink(SalesInquiryIssue.UpgradeEnterprise);
const openSelfHostedLink = () => {
window.open(LicenseLinks.CONTACT_SALES, '_blank');
};
const [openSalesLink] = useOpenSalesLink();
const isCloud = useSelector(isCurrentLicenseCloud);
@@ -42,11 +37,10 @@ function ContactSalesCTA() {
e.preventDefault();
if (isCloud) {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_PRICING, 'click_enterprise_contact_sales');
openSalesLink();
} else {
trackEvent('self_hosted_pricing', 'click_enterprise_contact_sales');
openSelfHostedLink();
}
openSalesLink();
}}
>
{formatMessage({id: 'pricing_modal.btn.contactSalesForQuote', defaultMessage: 'Contact Sales'})}

View File

@@ -10,8 +10,6 @@ import {CloudLinks, CloudProducts, LicenseSkus, ModalIdentifiers, MattermostFeat
import {fallbackStarterLimits, asGBString, hasSomeLimits} from 'utils/limits';
import {findOnlyYearlyProducts, findProductBySku} from 'utils/products';
import {getCloudContactUsLink, InquiryType, SalesInquiryIssue} from 'selectors/cloud';
import {trackEvent} from 'actions/telemetry_actions';
import {closeModal, openModal} from 'actions/views/modals';
import {subscribeCloudSubscription} from 'actions/cloud';
@@ -38,6 +36,8 @@ import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurch
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useOpenDowngradeModal from 'components/common/hooks/useOpenDowngradeModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {useOpenCloudZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import ExternalLink from 'components/external_link';
import DowngradeTeamRemovalModal from './downgrade_team_removal_modal';
@@ -64,15 +64,12 @@ function Content(props: ContentProps) {
const openPricingModalBackAction = useOpenPricingModal();
const isAdmin = useSelector(isCurrentUserSystemAdmin);
const contactSalesLink = useSelector(getCloudContactUsLink)(InquiryType.Sales, SalesInquiryIssue.UpgradeEnterprise);
const subscription = useSelector(selectCloudSubscription);
const currentProduct = useSelector(selectSubscriptionProduct);
const products = useSelector(selectCloudProducts);
const yearlyProducts = findOnlyYearlyProducts(products || {}); // pricing modal should now only show yearly products
const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical);
const currentSubscriptionIsMonthly = currentProduct?.recurring_interval === RecurringIntervals.MONTH;
const isEnterprise = currentProduct?.sku === CloudProducts.ENTERPRISE;
const isEnterpriseTrial = subscription?.is_free_trial === 'true';
@@ -124,6 +121,8 @@ function Content(props: ContentProps) {
const freeTierText = (!isStarter && !currentSubscriptionIsMonthly) ? formatMessage({id: 'pricing_modal.btn.contactSupport', defaultMessage: 'Contact Support'}) : formatMessage({id: 'pricing_modal.btn.downgrade', defaultMessage: 'Downgrade'});
const adminProfessionalTierText = currentSubscriptionIsMonthlyProfessional ? formatMessage({id: 'pricing_modal.btn.switch_to_annual', defaultMessage: 'Switch to annual billing'}) : formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'});
const [openContactSales] = useOpenSalesLink();
const [openContactSupport] = useOpenCloudZendeskSupportForm('Workspace downgrade', '');
const openCloudPurchaseModal = useOpenCloudPurchaseModal({});
const openCloudDelinquencyModal = useOpenCloudPurchaseModal({
isDelinquencyModal: true,
@@ -239,7 +238,7 @@ function Content(props: ContentProps) {
return {
action: () => {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_PRICING, 'click_enterprise_contact_sales');
window.open(contactSalesLink, '_blank');
openContactSales();
},
text: formatMessage({id: 'pricing_modal.btn.contactSales', defaultMessage: 'Contact Sales'}),
customClass: ButtonCustomiserClasses.active,
@@ -350,7 +349,7 @@ function Content(props: ContentProps) {
buttonDetails={{
action: () => {
if (!isStarter && !currentSubscriptionIsMonthly) {
window.open(contactSupportLink, '_blank');
openContactSupport();
return;
}

View File

@@ -6,7 +6,7 @@ import {Modal} from 'react-bootstrap';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {CloudLinks, LicenseLinks, ModalIdentifiers, SelfHostedProducts, LicenseSkus, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants';
import {CloudLinks, ModalIdentifiers, SelfHostedProducts, LicenseSkus, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants';
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
import {trackEvent} from 'actions/telemetry_actions';
@@ -27,6 +27,7 @@ import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn';
import ExternalLink from 'components/external_link';
import useCanSelfHostedSignup from 'components/common/hooks/useCanSelfHostedSignup';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {
useControlAirGappedSelfHostedPurchaseModal,
@@ -89,6 +90,7 @@ function SelfHostedContent(props: ContentProps) {
const isEnterprise = license.SkuShortName === LicenseSkus.Enterprise;
const isPostSelfHostedEnterpriseTrial = prevSelfHostedTrialLicense.IsLicensed === 'true';
const [openContactSales] = useOpenSalesLink();
const controlScreeningInProgressModal = useControlScreeningInProgressModal();
const controlAirgappedModal = useControlAirGappedSelfHostedPurchaseModal();
@@ -287,7 +289,7 @@ function SelfHostedContent(props: ContentProps) {
buttonDetails={(isPostSelfHostedEnterpriseTrial || !isAdmin) ? {
action: () => {
trackEvent('self_hosted_pricing', 'click_enterprise_contact_sales');
window.open(LicenseLinks.CONTACT_SALES, '_blank');
openContactSales();
},
text: formatMessage({id: 'pricing_modal.btn.contactSales', defaultMessage: 'Contact Sales'}),
customClass: ButtonCustomiserClasses.active,

View File

@@ -19,7 +19,7 @@ import {GlobalState} from 'types/store';
import {BillingDetails} from 'types/cloud/sku';
import {isModalOpen} from 'selectors/views/modals';
import {getCloudContactUsLink, InquiryType, getCloudDelinquentInvoices, isCloudDelinquencyGreaterThan90Days} from 'selectors/cloud';
import {getCloudDelinquentInvoices, isCloudDelinquencyGreaterThan90Days} from 'selectors/cloud';
import {isDevModeEnabled} from 'selectors/general';
import {ModalIdentifiers} from 'utils/constants';
@@ -29,6 +29,7 @@ import {completeStripeAddPaymentMethod, subscribeCloudSubscription} from 'action
import {ModalData} from 'types/actions';
import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription';
import {findOnlyYearlyProducts} from 'utils/products';
import {getCloudContactSalesLink, getCloudSupportLink} from 'utils/contact_support_sales';
const PurchaseModal = makeAsyncComponent('PurchaseModal', React.lazy(() => import('./purchase_modal')));
@@ -39,19 +40,27 @@ function mapStateToProps(state: GlobalState) {
const products = state.entities.cloud!.products;
const yearlyProducts = findOnlyYearlyProducts(products || {});
const customer = state.entities.cloud.customer;
const customerEmail = customer?.email || '';
const firstName = customer?.contact_first_name || '';
const lastName = customer?.contact_last_name || '';
const companyName = customer?.name || '';
const contactSalesLink = getCloudContactSalesLink(firstName, lastName, companyName, customerEmail, 'mattermost', 'in-product-cloud');
const contactSupportLink = getCloudSupportLink(customerEmail, 'Cloud purchase', '', window.location.host);
return {
show: isModalOpen(state, ModalIdentifiers.CLOUD_PURCHASE),
products,
yearlyProducts,
isDevMode: isDevModeEnabled(state),
contactSupportLink: getCloudContactUsLink(state)(InquiryType.Technical),
contactSupportLink,
invoices: getCloudDelinquentInvoices(state),
isCloudDelinquencyGreaterThan90Days: isCloudDelinquencyGreaterThan90Days(state),
isFreeTrial: subscription?.is_free_trial === 'true',
isComplianceBlocked: subscription?.compliance_blocked === 'true',
contactSalesLink: getCloudContactUsLink(state)(InquiryType.Sales),
contactSalesLink,
productId: subscription?.product_id,
customer: state.entities.cloud.customer,
customer,
currentTeam: getCurrentTeam(state),
theme: getTheme(state),
isDelinquencyModal,

View File

@@ -2,42 +2,8 @@
overflow: hidden;
height: 100%;
.shipping-address-section {
display: flex;
align-content: center;
padding: 0 96px;
padding-bottom: 28px;
font-weight: normal;
button.no-style {
padding-left: 0;
background: transparent;
outline: unset;
&:focus {
outline: unset;
}
}
#address-same-than-billing-address {
width: 20px;
height: 20px;
margin-top: auto;
margin-bottom: auto;
}
.Form-checkbox-label {
padding-left: 12px;
cursor: default;
font-family: 'Open Sans', sans-serif;
vertical-align: middle;
}
.billing_address_btn_text {
color: var(--center-channel-color);
font-family: 'Open Sans', sans-serif;
font-weight: normal;
}
& &__purchase-body {
overflow-y: auto;
}
>div {

View File

@@ -6,6 +6,7 @@
import React, {ReactNode} from 'react';
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl';
import classnames from 'classnames';
import {Stripe, StripeCardElementChangeEvent} from '@stripe/stripe-js';
import {loadStripe} from '@stripe/stripe-js/pure'; // https://github.com/stripe/stripe-js#importing-loadstripe-without-side-effects
import {Elements} from '@stripe/react-stripe-js';
@@ -17,7 +18,6 @@ import ComplianceScreenFailedSvg from 'components/common/svg_images_components/a
import AddressForm from 'components/payment_form/address_form';
import {t} from 'utils/i18n';
import {Address, CloudCustomer, Product, Invoice, areShippingDetailsValid, Feedback} from '@mattermost/types/cloud';
import {ActionResult} from 'mattermost-redux/types/actions';
import {localizeMessage, getNextBillingDate, getBlankAddressWithCountry} from 'utils/utils';
@@ -33,6 +33,7 @@ import {
ModalIdentifiers,
RecurringIntervals,
} from 'utils/constants';
import {goToMattermostContactSalesForm} from 'utils/contact_support_sales';
import PaymentDetails from 'components/admin_console/billing/payment_details';
import {STRIPE_CSS_SRC, STRIPE_PUBLIC_KEY} from 'components/payment_form/stripe';
@@ -53,6 +54,8 @@ import {ModalData} from 'types/actions';
import {Theme} from 'mattermost-redux/selectors/entities/preferences';
import {Address, CloudCustomer, Product, Invoice, areShippingDetailsValid, Feedback} from '@mattermost/types/cloud';
import {areBillingDetailsValid, BillingDetails} from '../../types/cloud/sku';
import {Team} from '@mattermost/types/teams';
@@ -462,6 +465,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
}
confirmSwitchToAnnual = () => {
const {customer} = this.props;
this.props.actions.openModal({
modalId: ModalIdentifiers.CONFIRM_SWITCH_TO_YEARLY,
dialogType: SwitchToYearlyPlanConfirmModal,
@@ -475,7 +479,11 @@ class PurchaseModal extends React.PureComponent<Props, State> {
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
'confirm_switch_to_annual_click_contact_sales',
);
window.open(this.props.contactSalesLink, '_blank');
const customerEmail = customer?.email || '';
const firstName = customer?.contact_first_name || '';
const lastName = customer?.contact_last_name || '';
const companyName = customer?.name || '';
goToMattermostContactSalesForm(firstName, lastName, companyName, customerEmail, 'mattermost', 'in-product-cloud');
},
},
});
@@ -812,7 +820,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
}
return (
<div className={this.state.processing ? 'processing' : ''}>
<div className={classnames('PurchaseModal__purchase-body', {processing: this.state.processing})}>
<div className='LHS'>
<h2 className='title'>{title}</h2>
<UpgradeSvg
@@ -1012,7 +1020,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
});
}}
contactSupportLink={
this.props.contactSalesLink
this.props.contactSupportLink
}
currentTeam={this.props.currentTeam}
onSuccess={() => {

View File

@@ -0,0 +1,132 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import classNames from 'classnames';
import {COUNTRIES} from 'utils/countries';
import DropdownInput from 'components/dropdown_input';
import Input from 'components/widgets/inputs/input/input';
import StateSelector from 'components/payment_form/state_selector';
interface Props {
type: 'shipping' | 'billing';
testPrefix?: string;
country: string;
changeCountry: (option: {value: string}) => void;
address: string;
changeAddress: (e: React.ChangeEvent<HTMLInputElement>) => void;
address2: string;
changeAddress2: (e: React.ChangeEvent<HTMLInputElement>) => void;
city: string;
changeCity: (e: React.ChangeEvent<HTMLInputElement>) => void;
state: string;
changeState: (postalCode: string) => void;
postalCode: string;
changePostalCode: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function Address(props: Props) {
const testPrefix = props.testPrefix || 'selfHostedPurchase';
const intl = useIntl();
let countrySelectorId = `${testPrefix}CountrySelector`;
let stateSelectorId = `${testPrefix}StateSelector`;
if (props.type === 'shipping') {
countrySelectorId += '_Shipping';
stateSelectorId += '_Shipping';
}
return (
<>
<div className={classNames({'third-dropdown-sibling-wrapper': props.type === 'shipping'})}>
<DropdownInput
testId={countrySelectorId}
onChange={props.changeCountry}
value={
props.country ? {value: props.country, label: props.country} : undefined
}
options={COUNTRIES.map((country) => ({
value: country.name,
label: country.name,
}))}
legend={intl.formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
placeholder={intl.formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
name={'billing_dropdown'}
/>
</div>
<div className='form-row'>
<Input
name='address'
type='text'
value={props.address}
onChange={props.changeAddress}
placeholder={intl.formatMessage({
id: 'payment_form.address',
defaultMessage: 'Address',
})}
required={true}
/>
</div>
<div className='form-row'>
<Input
name='address2'
type='text'
value={props.address2}
onChange={props.changeAddress2}
placeholder={intl.formatMessage({
id: 'payment_form.address_2',
defaultMessage: 'Address 2',
})}
/>
</div>
<div className='form-row'>
<Input
name='city'
type='text'
value={props.city}
onChange={props.changeCity}
placeholder={intl.formatMessage({
id: 'payment_form.city',
defaultMessage: 'City',
})}
required={true}
/>
</div>
<div className='form-row'>
<div className={classNames('form-row-third-1', {'second-dropdown-sibling-wrapper': props.type === 'billing', 'fourth-dropdown-sibling-wrapper': props.type === 'shipping'})}>
<StateSelector
testId={stateSelectorId}
country={props.country}
state={props.state}
onChange={props.changeState}
/>
</div>
<div className='form-row-third-2'>
<Input
name='postalCode'
type='text'
value={props.postalCode}
onChange={props.changePostalCode}
placeholder={intl.formatMessage({
id: 'payment_form.zipcode',
defaultMessage: 'Zip/Postal Code',
})}
required={true}
/>
</div>
</div>
</>
);
}

View File

@@ -4,18 +4,17 @@
import React from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {trackEvent} from 'actions/telemetry_actions';
import {getCloudContactUsLink, InquiryType} from 'selectors/cloud';
import {
TELEMETRY_CATEGORIES,
} from 'utils/constants';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import ExternalLink from 'components/external_link';
export default function ContactSalesLink() {
const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical);
const [, contactSalesLink] = useOpenSalesLink();
const intl = useIntl();
return (
<ExternalLink
@@ -26,7 +25,7 @@ export default function ContactSalesLink() {
'click_contact_sales',
);
}}
href={contactSupportLink}
href={contactSalesLink}
location='contact_sales_link'
>
{intl.formatMessage({id: 'self_hosted_signup.contact_sales', defaultMessage: 'Contact Sales'})}

View File

@@ -4,13 +4,11 @@
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {getCloudContactUsLink, InquiryType} from 'selectors/cloud';
import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg';
import AccessDeniedHappySvg from 'components/common/svg_images_components/access_denied_happy_svg';
import IconMessage from 'components/purchase_modal/icon_message';
import {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import ExternalLink from 'components/external_link';
interface Props {
@@ -20,7 +18,7 @@ interface Props {
}
export default function ErrorPage(props: Props) {
const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical);
const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error');
let formattedTitle = (
<FormattedMessage
id='admin.billing.subscription.paymentVerificationFailed'

View File

@@ -310,6 +310,15 @@ describe('SelfHostedPurchaseModal :: canSubmit', () => {
state: 'string',
country: 'string',
postalCode: '12345',
shippingSame: true,
shippingAddress: '',
shippingAddress2: '',
shippingCity: '',
shippingState: '',
shippingCountry: '',
shippingPostalCode: '',
cardName: 'string',
organization: 'string',
agreedTerms: true,
@@ -361,6 +370,21 @@ describe('SelfHostedPurchaseModal :: canSubmit', () => {
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if shipping address different and is not filled, can not submit', () => {
const state = makeHappyPathState();
state.shippingSame = false;
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
state.shippingAddress = 'more shipping info';
state.shippingAddress2 = 'more shipping info';
state.shippingCity = 'more shipping info';
state.shippingState = 'more shipping info';
state.shippingCountry = 'more shipping info';
state.shippingPostalCode = 'more shipping info';
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true);
});
it('if card number missing and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.cardFilled = false;

View File

@@ -26,8 +26,6 @@ import {GlobalState} from 'types/store';
import {isModalOpen} from 'selectors/views/modals';
import {isDevModeEnabled} from 'selectors/general';
import {COUNTRIES} from 'utils/countries';
import {
ModalIdentifiers,
StatTypes,
@@ -35,8 +33,6 @@ import {
} from 'utils/constants';
import CardInput, {CardInputType} from 'components/payment_form/card_input';
import StateSelector from 'components/payment_form/state_selector';
import DropdownInput from 'components/dropdown_input';
import BackgroundSvg from 'components/common/svg_images_components/background_svg';
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
@@ -47,6 +43,7 @@ import RootPortal from 'components/root_portal';
import useLoadStripe from 'components/common/hooks/useLoadStripe';
import useControlSelfHostedPurchaseModal from 'components/common/hooks/useControlSelfHostedPurchaseModal';
import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardAnalytics';
import ChooseDifferentShipping from 'components/choose_different_shipping';
import {ValueOf} from '@mattermost/types/utilities';
import {UserProfile} from '@mattermost/types/users';
@@ -64,6 +61,7 @@ import SuccessPage from './success_page';
import SelfHostedCard from './self_hosted_card';
import StripeProvider from './stripe_provider';
import Terms from './terms';
import Address from './address';
import useNoEscape from './useNoEscape';
import {SetPrefix, UnionSetActions} from './types';
@@ -73,12 +71,24 @@ import './self_hosted_purchase_modal.scss';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from './constants';
export interface State {
// billing address
address: string;
address2: string;
city: string;
state: string;
country: string;
postalCode: string;
// shipping address
shippingSame: boolean;
shippingAddress: string;
shippingAddress2: string;
shippingCity: string;
shippingState: string;
shippingCountry: string;
shippingPostalCode: string;
cardName: string;
organization: string;
agreedTerms: boolean;
@@ -113,6 +123,15 @@ export function makeInitialState(): State {
state: '',
country: '',
postalCode: '',
shippingSame: true,
shippingAddress: '',
shippingAddress2: '',
shippingCity: '',
shippingState: '',
shippingCountry: '',
shippingPostalCode: '',
cardName: '',
organization: '',
agreedTerms: false,
@@ -170,8 +189,18 @@ const simpleSetters: Array<Extract<keyof State, string>> = [
'address2',
'city',
'country',
'postalCode',
'state',
'postalCode',
// shipping address
'shippingSame',
'shippingAddress',
'shippingAddress2',
'shippingCity',
'shippingState',
'shippingCountry',
'shippingPostalCode',
'agreedTerms',
'cardFilled',
'cardName',
@@ -220,7 +249,7 @@ export function canSubmit(state: State, progress: ValueOf<typeof SelfHostedSignu
return false;
}
const validAddress = Boolean(
let validAddress = Boolean(
state.organization &&
state.address &&
state.city &&
@@ -228,6 +257,16 @@ export function canSubmit(state: State, progress: ValueOf<typeof SelfHostedSignu
state.postalCode &&
state.country,
);
if (!state.shippingSame) {
validAddress = validAddress && Boolean(
state.shippingAddress &&
state.shippingCity &&
state.shippingState &&
state.shippingPostalCode &&
state.shippingCountry,
);
}
const validCard = Boolean(
state.cardName &&
state.cardFilled,
@@ -366,16 +405,25 @@ export default function SelfHostedPurchaseModal(props: Props) {
try {
const [firstName, lastName] = inferNames(user, state.cardName);
const billingAddress = {
city: state.city,
country: state.country,
line1: state.address,
line2: state.address2,
postal_code: state.postalCode,
state: state.state,
};
signupCustomerResult = await Client4.createCustomerSelfHostedSignup({
first_name: firstName,
last_name: lastName,
billing_address: {
city: state.city,
country: state.country,
line1: state.address,
line2: state.address2,
postal_code: state.postalCode,
state: state.state,
billing_address: billingAddress,
shipping_address: state.shippingSame ? billingAddress : {
city: state.shippingCity,
country: state.shippingCountry,
line1: state.shippingAddress,
line2: state.shippingAddress2,
postal_code: state.shippingPostalCode,
state: state.shippingState,
},
organization: state.organization,
});
@@ -586,99 +634,76 @@ export default function SelfHostedPurchaseModal(props: Props) {
defaultMessage='Billing address'
/>
</div>
<DropdownInput
testId='selfHostedPurchaseCountrySelector'
onChange={(option: {value: string}) => {
<Address
type='billing'
country={state.country}
changeCountry={(option) => {
dispatch({type: 'set_country', data: option.value});
}}
value={
state.country ? {value: state.country, label: state.country} : undefined
}
options={COUNTRIES.map((country) => ({
value: country.name,
label: country.name,
}))}
legend={intl.formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
placeholder={intl.formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
name={'billing_dropdown'}
address={state.address}
changeAddress={(e) => {
dispatch({type: 'set_address', data: e.target.value});
}}
address2={state.address2}
changeAddress2={(e) => {
dispatch({type: 'set_address2', data: e.target.value});
}}
city={state.city}
changeCity={(e) => {
dispatch({type: 'set_city', data: e.target.value});
}}
state={state.state}
changeState={(state: string) => {
dispatch({type: 'set_state', data: state});
}}
postalCode={state.postalCode}
changePostalCode={(e) => {
dispatch({type: 'set_postalCode', data: e.target.value});
}}
/>
<div className='form-row'>
<Input
name='address'
type='text'
value={state.address}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({type: 'set_address', data: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.address',
defaultMessage: 'Address',
})}
required={true}
/>
</div>
<div className='form-row'>
<Input
name='address2'
type='text'
value={state.address2}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({type: 'set_address2', data: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.address_2',
defaultMessage: 'Address 2',
})}
/>
</div>
<div className='form-row'>
<Input
name='city'
type='text'
value={state.city}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({type: 'set_city', data: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.city',
defaultMessage: 'City',
})}
required={true}
/>
</div>
<div className='form-row'>
<div className='form-row-third-1'>
<StateSelector
testId='selfHostedPurchaseStateSelector'
country={state.country}
state={state.state}
onChange={(state: string) => {
dispatch({type: 'set_state', data: state});
<ChooseDifferentShipping
shippingIsSame={state.shippingSame}
setShippingIsSame={(val: boolean) => {
dispatch({type: 'set_shippingSame', data: val});
}}
/>
{!state.shippingSame && (
<>
<div className='section-title'>
<FormattedMessage
id='payment_form.shipping_address'
defaultMessage='Shipping Address'
/>
</div>
<Address
type='shipping'
country={state.shippingCountry}
changeCountry={(option) => {
dispatch({type: 'set_shippingCountry', data: option.value});
}}
address={state.shippingAddress}
changeAddress={(e) => {
dispatch({type: 'set_shippingAddress', data: e.target.value});
}}
address2={state.shippingAddress2}
changeAddress2={(e) => {
dispatch({type: 'set_shippingAddress2', data: e.target.value});
}}
city={state.shippingCity}
changeCity={(e) => {
dispatch({type: 'set_shippingCity', data: e.target.value});
}}
state={state.shippingState}
changeState={(state: string) => {
dispatch({type: 'set_shippingState', data: state});
}}
postalCode={state.shippingPostalCode}
changePostalCode={(e) => {
dispatch({type: 'set_shippingPostalCode', data: e.target.value});
}}
/>
</div>
<div className='form-row-third-2'>
<Input
name='postalCode'
type='text'
value={state.postalCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({type: 'set_postalCode', data: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.zipcode',
defaultMessage: 'Zip/Postal Code',
})}
required={true}
/>
</div>
</div>
</>
)}
<Terms
agreed={state.agreedTerms}
setAgreed={(data: boolean) => {

View File

@@ -4,19 +4,20 @@
.form-view {
display: flex;
overflow: hidden;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-content: top;
align-items: flex-start;
justify-content: center;
padding: 77px 107px;
color: var(--center-channel-color);
font-family: "Open Sans";
font-size: 16px;
font-weight: 600;
overflow-x: hidden;
overflow-y: auto;
.title {
font-size: 22px;
@@ -39,14 +40,12 @@
margin-right: 16px;
.DropdownInput {
z-index: 99999;
margin-top: 0;
}
}
.DropdownInput {
position: relative;
z-index: 999999;
height: 36px;
margin-bottom: 24px;
@@ -517,6 +516,9 @@
}
input[type=checkbox] {
width: 17px;
height: 17px;
flex-shrink: 0;
margin-right: 12px;
}

View File

@@ -16,7 +16,7 @@ import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {checkHadPriorTrial, isCurrentLicenseCloud, getSubscriptionProduct as selectSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
import {getBrowserTimezone} from 'utils/timezone';
import {CloudProducts, LicenseLinks, LicenseSkus} from 'utils/constants';
import {CloudProducts, LicenseSkus} from 'utils/constants';
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
@@ -28,7 +28,7 @@ function ADLDAPUpsellBanner() {
const dispatch = useDispatch();
const {formatMessage} = useIntl();
const openSalesLink = useOpenSalesLink();
const [openSalesLink] = useOpenSalesLink();
useEffect(() => {
dispatch(getPrevTrialLicense());
@@ -54,14 +54,6 @@ function ADLDAPUpsellBanner() {
const currentLicenseEndDate = new Date(parseInt(currentLicense?.ExpiresAt, 10));
const openLink = () => {
if (isCloud) {
openSalesLink();
} else {
window.open(LicenseLinks.CONTACT_SALES, '_blank');
}
};
const confirmBanner = (
<div className='ad_ldap_upsell_confirm'>
<div className='upsell-confirm-backdrop'/>
@@ -71,7 +63,7 @@ function ADLDAPUpsellBanner() {
<div className='btns-container'>
<button
className='confrim-btn learn-more'
onClick={openLink}
onClick={openSalesLink}
>
{formatMessage({id: 'adldap_upsell_banner.confirm.learn_more', defaultMessage: 'Learn more'})}
</button>
@@ -122,7 +114,7 @@ function ADLDAPUpsellBanner() {
btn = (
<button
className='ad-ldap-banner-btn'
onClick={openLink}
onClick={openSalesLink}
>
{formatMessage({id: 'adldap_upsell_banner.sales_btn', defaultMessage: 'Contact sales to use'})}
</button>

View File

@@ -3484,8 +3484,6 @@
"modal.manual_status.title_offline": "Вашето състояние е зададено на \"Извън линия\"",
"modal.manual_status.title_ooo": "Вашето състояние е зададено на \"Извън офиса\"",
"more_channels.create": "Създайте канал",
"more_channels.createClick": "Кликнете върху \"Създаване на нов канал\", за да създадете канал",
"more_channels.joining": "Присъединявне ...",
"more_channels.next": "Следващ",
"more_channels.noMore": "Няма повече канали за присъединяване",
"more_channels.prev": "Предишен",

View File

@@ -2127,7 +2127,7 @@
"admin.service.corsExposedHeadersTitle": "CORS-Exposed-Headers:",
"admin.service.corsHeadersEx": "X-My-Header",
"admin.service.corsTitle": "Erlaube Cross Origin Requests von:",
"admin.service.developerDesc": "Wenn wahr, werden Javascript Fehler in einer roten Zeile im oberen Bereich des Interfaces angezeigt. Nicht empfohlen für Produktionsumgebungen. ",
"admin.service.developerDesc": "Wenn wahr, werden Javascript Fehler in einer roten Zeile im oberen Bereich des Interfaces angezeigt. Nicht empfohlen für Produktionsumgebungen. Das Ändern dieser Einstellung erfordert einen Neustart des Servers, bevor sie wirksam wird.",
"admin.service.developerTitle": "Aktiviere Entwickler-Modus: ",
"admin.service.disableBotOwnerDeactivatedTitle": "Deaktiviere Bot-Konten, wenn der Besitzer deaktiviert ist:",
"admin.service.disableBotWhenOwnerIsDeactivated": "Wenn ein Benutzer deaktiviert ist, werden alle vom Benutzer verwalteten Bot-Konten deaktiviert. Um Bot-Konten wieder zu aktivieren, gehe zu [Integrationen > Bot-Konten]({siteURL}/_redirect/integrations/bots).",
@@ -3603,7 +3603,7 @@
"help.attaching.pasting.title": "Kopieren und Einfügen von Dateien",
"help.attaching.previewer.description": "Mattermost verfügt über eine integrierte Dateivorschau, die zum Anzeigen von Medien, Herunterladen von Dateien und zum Teilen öffentlicher Links verwendet wird. Wähle die Miniaturansicht einer angehängten Datei, um sie in der Dateivorschau zu öffnen.",
"help.attaching.previewer.title": "Dateivorschau",
"help.attaching.publicLinks.description": "Mit öffentlichen Links kannst du Dateianhänge mit Personen außerhalb deines Mattermost-Teams teilen. Öffne die Dateivorschau, indem du die Miniaturansicht eines Anhangs auswählen, und wähle dann **Öffentlichen Link erhalten**. Kopiere den angegebenen Link. Wenn der Link freigegeben und von einem anderen Benutzer geöffnet wird, wird die Datei automatisch heruntergeladen.",
"help.attaching.publicLinks.description": "Mit öffentlichen Links kannst du Dateianhänge mit Personen außerhalb deines Mattermost-Teams teilen. Öffne die Dateivorschau, indem du die Miniaturansicht eines Anhangs auswählen, und wähle dann **Öffentlichen Link erhalten**. Kopiere den angegebenen Link. Wenn der Link geteilt und von einem anderen Benutzer geöffnet wird, erfolgt ein automatischer Download der Datei.",
"help.attaching.publicLinks.title": "Links öffentlich teilen",
"help.attaching.publicLinks2": "Wenn die Option **Öffentlichen Link abrufen** in der Dateivorschau nicht sichtbar ist und du diese Funktion aktivieren möchtest, bitten deinen Systemadministrator, diese Funktion in der Systemkonsole unter **Site-Konfiguration > Öffentliche Links** zu aktivieren.",
"help.attaching.supported.description": "Wenn du versuchst, eine Vorschau eines nicht unterstützten Medientyps anzuzeigen, öffnet die Dateivorschau ein Standardsymbol für Medienanhänge. Die unterstützten Medienformate hängen stark von deinem Browser und Betriebssystem ab. Die folgenden Formate werden von Mattermost in den meisten Browsern unterstützt:",
@@ -4156,13 +4156,22 @@
"modal.manual_status.title_offline": "Dein Status wurde auf \"Offline\" gesetzt",
"modal.manual_status.title_ooo": "Dein Status ist auf \"Nicht im Büro\" gesetzt",
"more.details": "Mehr Details",
"more_channels.channel_purpose": "Kanal-Informationen: Mitgliedschaftsindikator: Beigetreten, Mitglieder {memberCount}, Zweck: {channelPurpose}",
"more_channels.count": "{count} Ergebnisse",
"more_channels.count_one": "1 Ergebnis",
"more_channels.count_zero": "Keine Ergebnisse",
"more_channels.create": "Kanal erstellen",
"more_channels.createClick": "Klicke auf 'Neuen Kanal erstellen' um einen Neuen zu erzeugen",
"more_channels.join": "Beitreten",
"more_channels.joining": "Betrete...",
"more_channels.hide_joined": "Verbundene Kanäle ausblenden",
"more_channels.hide_joined_checked": "Kontrollkästchen Verbundene Kanäle ausblenden, aktiviert",
"more_channels.hide_joined_not_checked": "Kontrollkästchen Verbundene Kanäle ausblenden, deaktiviert",
"more_channels.joined": "Verknüpft",
"more_channels.membership_indicator": "Mitgliedschaftsindikator: Beigetreten",
"more_channels.next": "Weiter",
"more_channels.noMore": "Keine weiteren Kanäle, denen beigetreten werden kann",
"more_channels.noArchived": "Keine archivierten Kanäle",
"more_channels.noMore": "Keine Ergebnisse für \"{text}\"",
"more_channels.noPublic": "Keine öffentlichen Kanäle",
"more_channels.prev": "Zurück",
"more_channels.searchError": "Versuche, nach anderen Stichworten zu suchen, auf Tippfehlern zu prüfen oder die Filter anzupassen.",
"more_channels.show_archived_channels": "Anzeigen: Archivierte Kanäle",
"more_channels.show_public_channels": "Anzeigen: Öffentliche Kanäle",
"more_channels.title": "Weitere Kanäle",
@@ -4372,6 +4381,7 @@
"payment_form.no_billing_address": "Keine Rechnungsadresse hinzugefügt",
"payment_form.no_credit_card": "Keine Kreditkarte hinzugefügt",
"payment_form.saved_payment_method": "Zahlungsmethode speichern",
"payment_form.shipping_address": "Lieferadresse",
"payment_form.zipcode": "Postleitzahl/Zip",
"pending_post_actions.cancel": "Abbrechen",
"pending_post_actions.retry": "Erneut versuchen",

View File

@@ -4378,6 +4378,7 @@
"payment_form.no_billing_address": "No billing address added",
"payment_form.no_credit_card": "No credit card added",
"payment_form.saved_payment_method": "Saved Payment Method",
"payment_form.shipping_address": "Shipping Address",
"payment_form.zipcode": "Zip/Postal Code",
"payment.card_number": "Card Number",
"payment.field_required": "This field is required",

View File

@@ -4153,9 +4153,6 @@
"modal.manual_status.title_ooo": "Your Status is Set to 'Out of Office'",
"more.details": "More details",
"more_channels.create": "Create Channel",
"more_channels.createClick": "Click 'Create New Channel' to make a new one",
"more_channels.join": "Join",
"more_channels.joining": "Joining...",
"more_channels.next": "Next",
"more_channels.noMore": "No more channels to join",
"more_channels.prev": "Previous",

View File

@@ -255,6 +255,7 @@
"admin.billing.company_info_edit.sameAsBillingAddress": "Igual que la dirección de facturación",
"admin.billing.company_info_edit.save": "Guardar información",
"admin.billing.company_info_edit.title": "Editar información de la empresa",
"admin.billing.deleteWorkspace.failureModal.buttonText": "Prueba de nuevo",
"admin.billing.history.allPaymentsShowHere": "Todos sus pagos mensuales se mostrarán aquí",
"admin.billing.history.date": "Fecha",
"admin.billing.history.description": "Descripción",
@@ -3898,9 +3899,6 @@
"modal.manual_status.title_ooo": "Tu estado actual es \"Fuera de Oficina\"",
"more.details": "Más detalles",
"more_channels.create": "Crear Canal",
"more_channels.createClick": "Haz clic en 'Crear Nuevo Canal' para crear uno nuevo",
"more_channels.join": "Unirse",
"more_channels.joining": "Uniendo...",
"more_channels.next": "Siguiente",
"more_channels.noMore": "No hay más canales para unirse",
"more_channels.prev": "Anterior",

View File

@@ -3707,9 +3707,6 @@
"modal.manual_status.title_ooo": "وضعیت شما روی \"خارج از دفتر\" تنظیم شده است",
"more.details": "جزئیات بیشتر",
"more_channels.create": "ایجاد کانال",
"more_channels.createClick": "برای ایجاد کانال جدید روی \"ایجاد کانال جدید\" کلیک کنید",
"more_channels.join": "پیوستن",
"more_channels.joining": "پیوستن...",
"more_channels.next": "بعد",
"more_channels.noMore": "کانال دیگری برای پیوستن وجود ندارد",
"more_channels.prev": "قبلی",

View File

@@ -3736,9 +3736,6 @@
"modal.manual_status.title_offline": "Votre statut est défini sur « Hors ligne »",
"modal.manual_status.title_ooo": "Votre statut est défini sur « Absent du bureau »",
"more_channels.create": "Créer un canal",
"more_channels.createClick": "Veuillez cliquer sur « Créer un nouveau canal » pour en créer un nouveau",
"more_channels.join": "Rejoindre",
"more_channels.joining": "Accès en cours...",
"more_channels.next": "Suivant",
"more_channels.noMore": "Il n'y a plus d'autre canal que vous pouvez rejoindre",
"more_channels.prev": "Précédent",

View File

@@ -3920,9 +3920,6 @@
"modal.manual_status.title_ooo": "Az Ön állapota \"Irodán kívül\" -re van állítva",
"more.details": "További információ",
"more_channels.create": "Csatorna létrehozása",
"more_channels.createClick": "Kattintson az \"Új csatorna létrehozása\" gombra egy új létrehozásához",
"more_channels.join": "Csatlakozás",
"more_channels.joining": "Csatlakozás...",
"more_channels.next": "Következő",
"more_channels.noMore": "Nincs több beszélgetés amelyhez csatlakozni lehetne",
"more_channels.prev": "Előző",

View File

@@ -2997,8 +2997,6 @@
"modal.manual_status.title_offline": "Il tuo stato è \"Non in linea\"",
"modal.manual_status.title_ooo": "Il tuo stato è \"Fuori sede\"",
"more_channels.create": "Crea canale",
"more_channels.createClick": "Click 'Crea un nuovo canale' per crearne uno nuovo",
"more_channels.joining": "Accoppiamento...",
"more_channels.next": "Prossimo",
"more_channels.noMore": "Nessun altro canale in cui entrare",
"more_channels.prev": "Precedente",

View File

@@ -387,7 +387,7 @@
"admin.billing.subscription.privateCloudCard.contactSalesy": "営業担当に問い合わせる",
"admin.billing.subscription.privateCloudCard.contactSupport": "サポートに連絡する",
"admin.billing.subscription.privateCloudCard.freeTrial.description": "私たちは、お客様のニーズにお応えすることを大切にしています。サブスクリプション、請求書作成、トライアルに関するご質問は、営業までお問い合わせください。",
"admin.billing.subscription.privateCloudCard.freeTrial.title": "トライアルに関する質問はこちら",
"admin.billing.subscription.privateCloudCard.freeTrial.title": "トライアルに関する質問がありますか?",
"admin.billing.subscription.privateCloudCard.upgradeNow": "今すぐアップグレード",
"admin.billing.subscription.proratedPayment.substitle": "{selectedProductName}にアップグレードしていただきありがとうございます。このプランのすべての機能にアクセスするには、数分後にワークスペースをチェックしてください。現在ご利用中の{currentProductName}プランと{selectedProductName}プランの料金については、請求サイクルの残り日数とユーザー数に応じた額を請求させていただきます。",
"admin.billing.subscription.proratedPayment.title": "現在、{selectedProductName} を利用しています",
@@ -1989,6 +1989,8 @@
"admin.requestButton.requestFailure": "テストが失敗しました: {error}",
"admin.requestButton.requestSuccess": "テストが成功しました",
"admin.reset_email.cancel": "キャンセル",
"admin.reset_email.currentPassword": "現在のパスワード",
"admin.reset_email.missing_current_password": "現在のパスワードを入力してください。",
"admin.reset_email.newEmail": "新しい電子メールアドレス",
"admin.reset_email.reset": "リセット",
"admin.reset_email.titleReset": "電子メールを更新する",
@@ -2125,7 +2127,7 @@
"admin.service.corsExposedHeadersTitle": "CORS Exposedヘッダ:",
"admin.service.corsHeadersEx": "X-My-Header",
"admin.service.corsTitle": "クロスオリジンリクエストを許可する:",
"admin.service.developerDesc": "有効な場合、JavaScriptのエラーはユーザーインターフェイス上部の紫色のバーに表示されます。本番環境での使用はお勧めできません。 ",
"admin.service.developerDesc": "有効な場合、JavaScriptのエラーはユーザーインターフェイス上部の紫色のバーに表示されます。本番環境での使用はお勧めできません。",
"admin.service.developerTitle": "開発者モードを有効にする: ",
"admin.service.disableBotOwnerDeactivatedTitle": "オーナーが無効化された際にBotアカウントを無効化する:",
"admin.service.disableBotWhenOwnerIsDeactivated": "ユーザーが無効化された際、そのユーザーが管理していたすべてのBotアカウントを無効化します。Botアカウントを再び有効にするには、[統合機能 > Botアカウント]({siteURL}/_redirect/integrations/bots) から設定してください。",
@@ -3128,7 +3130,7 @@
"create_post.deactivated": "**無効化されたユーザー** のいるアーカイブされたチャンネルを見ています。新しいメッセージは投稿できません。",
"create_post.error_message": "メッセージが長すぎます。文字数: {length}/{limit}",
"create_post.fileProcessing": "処理しています...",
"create_post.file_limit_sticky_banner.admin_message": "新たにアップロードすると古いファイルから自動的にアーカイブされます。古いファイルを削除するか、<a>有料プランにアップグレード</a>することで、再度表示できるようになります",
"create_post.file_limit_sticky_banner.admin_message": "新たにアップロードすると古いファイルから自動的にアーカイブされます。古いファイルを削除するか、<a>有料プランにアップグレード</a>することで、再度表示できるようになります",
"create_post.file_limit_sticky_banner.messageTitle": "無料プランではファイル容量が {storageGB} に制限されます。",
"create_post.file_limit_sticky_banner.non_admin_message": "新たにアップロードすると古いファイルから自動的にアーカイブされます。再度表示するには、<a>管理者に有料プランへアップグレードするよう通知してください</a>。",
"create_post.file_limit_sticky_banner.snooze_tooltip": "{snoozeDays}日間スヌーズする",
@@ -4154,13 +4156,22 @@
"modal.manual_status.title_offline": "ステータスが \"オフライン\" になりました",
"modal.manual_status.title_ooo": "ステータスが \"外出中\" になりました",
"more.details": "もっと詳しく",
"more_channels.channel_purpose": "チャンネル情報: メンバーシップ状況: 加入済, メンバー数 {memberCount} , 目的: {channelPurpose}",
"more_channels.count": "{count}件",
"more_channels.count_one": "1件",
"more_channels.count_zero": "0件",
"more_channels.create": "チャンネルを作成する",
"more_channels.createClick": "新しいチャンネルを作成するには「チャンネルを作成する」をクリックしてください",
"more_channels.join": "参加",
"more_channels.joining": "参加しています....",
"more_channels.hide_joined": "参加したことを表示しない",
"more_channels.hide_joined_checked": "チャンネルに参加したことを表示しないチェックボックスがチェック済です",
"more_channels.hide_joined_not_checked": "チャンネルに参加したことを表示しないチェックボックスがチェックされていません",
"more_channels.joined": "参加済",
"more_channels.membership_indicator": "メンバーシップ状況: 参加済",
"more_channels.next": "次へ",
"more_channels.noMore": "参加できるチャンネルありません",
"more_channels.noArchived": "アーカイブされたチャンネルありません",
"more_channels.noMore": "\"{text}\"の結果はありません",
"more_channels.noPublic": "公開チャンネルはありません",
"more_channels.prev": "前へ",
"more_channels.searchError": "違うキーワードで検索してみたり、入力ミスを確認したり、フィルター設定を変更して再度お試しください。",
"more_channels.show_archived_channels": "表示: アーカイブチャンネル",
"more_channels.show_public_channels": "表示: 公開チャンネル",
"more_channels.title": "他のチャンネル",
@@ -4370,6 +4381,7 @@
"payment_form.no_billing_address": "請求先住所が追加されませんでした",
"payment_form.no_credit_card": "クレジットカードが追加されませんでした",
"payment_form.saved_payment_method": "支払い方法を保存する",
"payment_form.shipping_address": "配送先住所",
"payment_form.zipcode": "郵便番号",
"pending_post_actions.cancel": "キャンセル",
"pending_post_actions.retry": "再試行",
@@ -4385,6 +4397,14 @@
"plan.self_serve": "セルフサービス",
"pluggable.errorOccurred": "プラグイン {pluginId} でエラーが発生しました。",
"pluggable.errorRefresh": "更新しますか?",
"pluggable_rhs.tourtip.boards.access": "右側のApp barの Boards アイコンから、リンクされた boards にアクセスできます。",
"pluggable_rhs.tourtip.boards.click": "この右側のパネルから boards をクリックします。",
"pluggable_rhs.tourtip.boards.review": "チャンネルから board の更新を確認します。",
"pluggable_rhs.tourtip.boards.title": "{count} 個のリンクされた {num, plural, one {board} other {boards}} にアクセスしましょう!",
"pluggable_rhs.tourtip.playbooks.access": "右側のApp barの Playbooks アイコンから、リンクされた playbooks にアクセスできます。",
"pluggable_rhs.tourtip.playbooks.click": "この右側のパネルから playbooks をクリックします。",
"pluggable_rhs.tourtip.playbooks.review": "チャンネルから playbook の更新を確認します。",
"pluggable_rhs.tourtip.playbooks.title": "{count} 個のリンクされた {num, plural, one {playbook} other {playbooks}} にアクセスしましょう。",
"post.ariaLabel.attachment": ", 1 添付ファイル",
"post.ariaLabel.attachmentMultiple": ", {attachmentCount} 添付ファイル",
"post.ariaLabel.message": "{time} {date}, {authorName} が, {message} を書きました",
@@ -4394,6 +4414,8 @@
"post.ariaLabel.reaction": ", 1 リアクション",
"post.ariaLabel.reactionMultiple": ", {reactionCount} リアクション",
"post.ariaLabel.replyMessage": "{time} {date}, {authorName} が, {message} と返信しました",
"post.reminder.acknowledgement": "{username} からのこのメッセージについて、{reminderDate}, {reminderTime} にリマインドされます: {permaLink}",
"post.reminder.systemBot": "{username} からのこのメッセージについてのリマインドです: {permaLink}",
"post_body.check_for_out_of_channel_groups_mentions.message": "彼らはチャンネルにいないため、このメンションによる通知は行われませんでした。また、彼らはリンクされたグループのメンバーではないため、チャンネルに追加することもできません。彼らをこのチャンネルに追加するには、リンクされたグループに追加しなければなりません。",
"post_body.check_for_out_of_channel_mentions.link.and": " と ",
"post_body.check_for_out_of_channel_mentions.link.private": "彼らを非公開チャンネルに追加しますか",
@@ -4434,6 +4456,13 @@
"post_info.message.visible.compact": " (あなただけが見ることができます)",
"post_info.permalink": "リンクをコピーする",
"post_info.pin": "チャンネルにピン留め",
"post_info.post_reminder.menu": "リマインダー",
"post_info.post_reminder.sub_menu.custom": "カスタム",
"post_info.post_reminder.sub_menu.header": "リマインダーを設定する:",
"post_info.post_reminder.sub_menu.one_hour": "1時間",
"post_info.post_reminder.sub_menu.thirty_minutes": "30分",
"post_info.post_reminder.sub_menu.tomorrow": "明日",
"post_info.post_reminder.sub_menu.two_hours": "2時間",
"post_info.reply": "返信する",
"post_info.submenu.icon": "サブメニューアイコン",
"post_info.submenu.mobile": "モバイルサブメニュー",
@@ -4462,6 +4491,9 @@
"post_priority.requested_ack.description": "メッセージに確認ボタンが表示されます",
"post_priority.requested_ack.text": "確認を要求する",
"post_priority.you.acknowledge": "(あなた)",
"post_reminder.custom_time_picker_modal.header": "リマインダーを設定する",
"post_reminder.custom_time_picker_modal.submit_button": "リマインダーを設定する",
"post_reminder_custom_time_picker_modal.defaultMsg": "リマインダーを設定する",
"postlist.toast.history": "メッセージの履歴を確認しています",
"postlist.toast.newMessages": "新しい {count, number} {count, plural, one {メッセージ} other {メッセージ}}",
"postlist.toast.newMessagesSince": "{date} {isToday, select, true {} other {以降}} に投稿された新しい {count, number} {count, plural, one {メッセージ} other {メッセージ}}",
@@ -5388,7 +5420,7 @@
"user.settings.notifications.email.disabled": "電子メール通知は有効化されていません",
"user.settings.notifications.email.disabled_long": "電子メール通知はシステム管理者によって有効化されていません。",
"user.settings.notifications.email.everyHour": "1時間毎",
"user.settings.notifications.email.everyXMinutes": "{count}ごと",
"user.settings.notifications.email.everyXMinutes": "{count, plural, one {分} other {{count, number} 分}}ごと",
"user.settings.notifications.email.immediately": "すぐに",
"user.settings.notifications.email.never": "通知しない",
"user.settings.notifications.email.send": "電子メール通知を送信する",

View File

@@ -2883,8 +2883,6 @@
"modal.manual_status.title_offline": "상태가 \"오프라인\"이 되셨습니다",
"modal.manual_status.title_ooo": "상태가 \"오프라인\"이 되셨습니다",
"more_channels.create": "채널 만들기",
"more_channels.createClick": "'새로 만들기'를 클릭하여 새로운 채널을 만드세요",
"more_channels.joining": "참가 중...",
"more_channels.next": "다음",
"more_channels.noMore": "가입할 수 있는 채널이 없습니다",
"more_channels.prev": "이전",

View File

@@ -4155,9 +4155,6 @@
"modal.manual_status.title_ooo": "Je status is ingesteld op \"Out of Office\"",
"more.details": "Meer details",
"more_channels.create": "Kanaal aanmaken",
"more_channels.createClick": "Klik 'Maak nieuw kanaal' om een nieuw kanaal te maken",
"more_channels.join": "Deelnemen",
"more_channels.joining": "Lid worden...",
"more_channels.next": "Volgende",
"more_channels.noMore": "Geen kanalen beschikbaar waar aan deelgenomen kan worden",
"more_channels.prev": "Vorige",

View File

@@ -2127,7 +2127,7 @@
"admin.service.corsExposedHeadersTitle": "Eksponowane nagłówki CORS:",
"admin.service.corsHeadersEx": "X-Mój-Header",
"admin.service.corsTitle": "Pozwól na zapytania Cross-domain z:",
"admin.service.developerDesc": "Gdy włączone, błędy JavaScript wyświetlane są na czerwonym pasku u góry interfejsu użytkownika. Nie zalecane w wersji produkcyjnej. ",
"admin.service.developerDesc": "Gdy włączone, błędy JavaScript wyświetlane są na czerwonym pasku u góry interfejsu użytkownika. Nie zalecane w wersji produkcyjnej. Zmiana tego wymaga restartu serwera zanim zacznie działać.",
"admin.service.developerTitle": "Włączyć Tryb Dewelopera: ",
"admin.service.disableBotOwnerDeactivatedTitle": "Wyłącz konta botów jeśli właściciel jest dezaktywowany:",
"admin.service.disableBotWhenOwnerIsDeactivated": "Kiedy użytkownik jest dezaktywowany, wyłącza wszystkie konta bot zarządzane przez użytkownika. Aby ponownie włączyć konta botów, przejdź do [Integracje > Konta Botów]({siteURL}/_redirect/integrations/bots).",
@@ -4156,13 +4156,22 @@
"modal.manual_status.title_offline": "Twój status został ustawiony na \"Offline\"",
"modal.manual_status.title_ooo": "Twój status został ustawiony na \"Poza biurem\"",
"more.details": "Więcej informacji",
"more_channels.channel_purpose": "Informacje o kanale: Wskaźnik członkostwa: Dołączyło, liczba członków {memberCount}, Propozycje: {channelPurpose}",
"more_channels.count": "{count} Wyników",
"more_channels.count_one": "1 Wynik",
"more_channels.count_zero": "0 Wyników",
"more_channels.create": "Stwórz kanał",
"more_channels.createClick": "Kliknij przycisk 'Utwórz nowy kanał', aby dodać nowy",
"more_channels.join": "Dołącz do",
"more_channels.joining": "Dołączanie...",
"more_channels.hide_joined": "Ukryj dołączonych",
"more_channels.hide_joined_checked": "Pole wyboru Ukryj połączone kanały, zaznaczone",
"more_channels.hide_joined_not_checked": "Pole wyboru Ukryj połączone kanały, nie zaznaczone",
"more_channels.joined": "Dołączył",
"more_channels.membership_indicator": "Wskaźnik członkostwa: Dołączył",
"more_channels.next": "Dalej",
"more_channels.noMore": "Brak kanałów",
"more_channels.noArchived": "Brak zarchiwizowanych kanałów",
"more_channels.noMore": "Brak wyników dla \"{text}\"",
"more_channels.noPublic": "Brak kanałów publicznych",
"more_channels.prev": "Wstecz",
"more_channels.searchError": "Spróbuj wyszukać inne słowa kluczowe, sprawdzić literówki lub dostosować filtry.",
"more_channels.show_archived_channels": "Pokaż: Archiwizowane kanały",
"more_channels.show_public_channels": "Pokaż: Publiczne kanały",
"more_channels.title": "Więcej Kanałów",
@@ -4372,6 +4381,7 @@
"payment_form.no_billing_address": "Nie dodano adresu rozliczeniowego",
"payment_form.no_credit_card": "Nie dodano karty kredytowej",
"payment_form.saved_payment_method": "Zapisana Metoda Płatności",
"payment_form.shipping_address": "Adres do wysyłki",
"payment_form.zipcode": "Kod Pocztowy",
"pending_post_actions.cancel": "Anuluj",
"pending_post_actions.retry": "Ponów",

View File

@@ -3216,8 +3216,6 @@
"modal.manual_status.title_offline": "Seu Status está configurado para \"Desconectado\"",
"modal.manual_status.title_ooo": "Seu Status está configurado para \"Fora do Escritório\"",
"more_channels.create": "Criar Canal",
"more_channels.createClick": "Clique em 'Criar Novo Canal' para fazer um novo",
"more_channels.joining": "Juntando...",
"more_channels.next": "Próximo",
"more_channels.noMore": "Não há mais canais para participar",
"more_channels.prev": "Anterior",

View File

@@ -3306,8 +3306,6 @@
"modal.manual_status.title_offline": "Starea dvs. este setată la \"Offline\"",
"modal.manual_status.title_ooo": "Starea dvs. este setată la \"Plecat din birou\"",
"more_channels.create": "Creați un nou canal",
"more_channels.createClick": "Dați clic pe \"Creați un nou canal\" pentru a crea unul nou",
"more_channels.joining": "Aderarea...",
"more_channels.next": "Următor",
"more_channels.noMore": "Nu mai există canale care să se alăture",
"more_channels.prev": "Anterior",

View File

@@ -4157,9 +4157,6 @@
"modal.manual_status.title_ooo": "Ваш статус установлен на \"Не на работе\"",
"more.details": "Подробнее",
"more_channels.create": "Создать канал",
"more_channels.createClick": "Нажмите 'Создать канал' для создания нового канала",
"more_channels.join": "Присоединиться",
"more_channels.joining": "Присоединяемся...",
"more_channels.next": "Далее",
"more_channels.noMore": "Доступных каналов не найдено",
"more_channels.prev": "Предыдущая",

View File

@@ -4157,9 +4157,6 @@
"modal.manual_status.title_ooo": "Din status är satt till \"Inte på kontoret\"",
"more.details": "Mer information",
"more_channels.create": "Skapa kanal",
"more_channels.createClick": "Tryck 'Skapa ny kanal' för att skapa en ny",
"more_channels.join": "Gå med",
"more_channels.joining": "Ansluter...",
"more_channels.next": "Nästa",
"more_channels.noMore": "Det finns inga fler kanaler att gå med i",
"more_channels.prev": "Föregående",

Some files were not shown because too many files have changed in this diff Show More