mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
Merge branch 'master' into MM-60555
This commit is contained in:
commit
396beac61d
@ -129,12 +129,10 @@ test('Post actions tab support', async ({pw, axe}) => {
|
||||
// # Press arrow right
|
||||
await channelsPage.postDotMenu.remindMenuItem.press('ArrowRight');
|
||||
|
||||
// * Reminder menu should be visible and have focused
|
||||
channelsPage.postReminderMenu.toBeVisible();
|
||||
await expect(channelsPage.postReminderMenu.container).toBeFocused();
|
||||
// * Reminder menu should be visible
|
||||
expect(channelsPage.postReminderMenu.container).toBeVisible();
|
||||
|
||||
// * Should move focus to 30 mins after arrow down
|
||||
await channelsPage.postReminderMenu.container.press('ArrowDown');
|
||||
// * Should have focus on 30 mins after submenu opens
|
||||
expect(await channelsPage.postReminderMenu.thirtyMinsMenuItem).toBeFocused();
|
||||
|
||||
// * Should move focus to 1 hour after arrow down
|
||||
|
@ -8,15 +8,6 @@ else
|
||||
PLATFORM := $(shell uname)
|
||||
endif
|
||||
|
||||
# Set an environment variable on Linux used to resolve `docker.host.internal` inconsistencies with
|
||||
# docker. This can be reworked once https://github.com/docker/for-linux/issues/264 is resolved
|
||||
# satisfactorily.
|
||||
ifeq ($(PLATFORM),Linux)
|
||||
export IS_LINUX = -linux
|
||||
else
|
||||
export IS_LINUX =
|
||||
endif
|
||||
|
||||
# Detect Apple Silicon and set a flag.
|
||||
ifeq ($(shell uname)/$(shell uname -m),Darwin/arm64)
|
||||
ARM_BASED_MAC = true
|
||||
|
@ -144,10 +144,12 @@ services:
|
||||
image: "prom/prometheus:v2.46.0"
|
||||
user: root
|
||||
volumes:
|
||||
- "./docker/prometheus${IS_LINUX}.yml:/etc/prometheus/prometheus.yml"
|
||||
- "./docker/prometheus.yml:/etc/prometheus/prometheus.yml"
|
||||
- "/var/run/docker.sock:/var/run/docker.sock"
|
||||
networks:
|
||||
- mm-test
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
grafana:
|
||||
image: "grafana/grafana:10.4.2"
|
||||
volumes:
|
||||
|
@ -1,8 +0,0 @@
|
||||
global:
|
||||
scrape_interval: 5s
|
||||
evaluation_interval: 60s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'mattermost'
|
||||
static_configs:
|
||||
- targets: ['172.17.0.1:8067']
|
@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -54,6 +55,8 @@ func TestCreateCPAField(t *testing.T) {
|
||||
}, "an invalid field should be rejected")
|
||||
|
||||
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
|
||||
webSocketClient := th.CreateConnectedWebSocketClient(t)
|
||||
|
||||
name := model.NewId()
|
||||
field := &model.PropertyField{
|
||||
Name: fmt.Sprintf(" %s\t", name), // name should be sanitized
|
||||
@ -67,6 +70,27 @@ func TestCreateCPAField(t *testing.T) {
|
||||
require.NotZero(t, createdField.ID)
|
||||
require.Equal(t, name, createdField.Name)
|
||||
require.Equal(t, "default", createdField.Attrs["visibility"])
|
||||
|
||||
t.Run("a websocket event should be fired as part of the field creation", func(t *testing.T) {
|
||||
var wsField model.PropertyField
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case event := <-webSocketClient.EventChannel:
|
||||
if event.EventType() == model.WebsocketEventCPAFieldCreated {
|
||||
fieldData, err := json.Marshal(event.GetData()["field"])
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(fieldData, &wsField))
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
|
||||
require.NotEmpty(t, wsField.ID)
|
||||
require.Equal(t, createdField, &wsField)
|
||||
})
|
||||
}, "a user with admin permissions should be able to create the field")
|
||||
}
|
||||
|
||||
@ -149,6 +173,8 @@ func TestPatchCPAField(t *testing.T) {
|
||||
})
|
||||
|
||||
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
|
||||
webSocketClient := th.CreateConnectedWebSocketClient(t)
|
||||
|
||||
field := &model.PropertyField{
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
@ -163,6 +189,27 @@ func TestPatchCPAField(t *testing.T) {
|
||||
CheckOKStatus(t, resp)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, newName, patchedField.Name)
|
||||
|
||||
t.Run("a websocket event should be fired as part of the field patch", func(t *testing.T) {
|
||||
var wsField model.PropertyField
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case event := <-webSocketClient.EventChannel:
|
||||
if event.EventType() == model.WebsocketEventCPAFieldUpdated {
|
||||
fieldData, err := json.Marshal(event.GetData()["field"])
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(fieldData, &wsField))
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
|
||||
require.NotEmpty(t, wsField.ID)
|
||||
require.Equal(t, patchedField, &wsField)
|
||||
})
|
||||
}, "a user with admin permissions should be able to patch the field")
|
||||
}
|
||||
|
||||
@ -197,6 +244,8 @@ func TestDeleteCPAField(t *testing.T) {
|
||||
})
|
||||
|
||||
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
|
||||
webSocketClient := th.CreateConnectedWebSocketClient(t)
|
||||
|
||||
field := &model.PropertyField{
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
@ -213,6 +262,26 @@ func TestDeleteCPAField(t *testing.T) {
|
||||
deletedField, appErr := th.App.GetCPAField(createdField.ID)
|
||||
require.Nil(t, appErr)
|
||||
require.NotZero(t, deletedField.DeleteAt)
|
||||
|
||||
t.Run("a websocket event should be fired as part of the field deletion", func(t *testing.T) {
|
||||
var fieldID string
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case event := <-webSocketClient.EventChannel:
|
||||
if event.EventType() == model.WebsocketEventCPAFieldDeleted {
|
||||
var ok bool
|
||||
fieldID, ok = event.GetData()["field_id"].(string)
|
||||
require.True(t, ok)
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
|
||||
require.Equal(t, createdField.ID, fieldID)
|
||||
})
|
||||
}, "a user with admin permissions should be able to delete the field")
|
||||
}
|
||||
|
||||
@ -470,6 +539,8 @@ func TestPatchCPAValues(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
|
||||
|
||||
t.Run("any team member should be able to create their own values", func(t *testing.T) {
|
||||
webSocketClient := th.CreateConnectedWebSocketClient(t)
|
||||
|
||||
values := map[string]json.RawMessage{}
|
||||
value := "Field Value"
|
||||
values[createdField.ID] = json.RawMessage(fmt.Sprintf(`" %s "`, value)) // value should be sanitized
|
||||
@ -490,6 +561,27 @@ func TestPatchCPAValues(t *testing.T) {
|
||||
actualValue = ""
|
||||
require.NoError(t, json.Unmarshal(values[createdField.ID], &actualValue))
|
||||
require.Equal(t, value, actualValue)
|
||||
|
||||
t.Run("a websocket event should be fired as part of the value changes", func(t *testing.T) {
|
||||
var wsValues map[string]json.RawMessage
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case event := <-webSocketClient.EventChannel:
|
||||
if event.EventType() == model.WebsocketEventCPAValuesUpdated {
|
||||
valuesData, err := json.Marshal(event.GetData()["values"])
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(valuesData, &wsValues))
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
|
||||
require.NotEmpty(t, wsValues)
|
||||
require.Equal(t, patchedValues, wsValues)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("any team member should be able to patch their own values", func(t *testing.T) {
|
||||
|
@ -91,7 +91,11 @@ func TestSubmitMetrics(t *testing.T) {
|
||||
|
||||
t.Run("metrics enabled and valid", func(t *testing.T) {
|
||||
metricsMock := setupMetricsMock()
|
||||
metricsMock.On("IncrementClientLongTasks", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return()
|
||||
metricsMock.On("IncrementClientLongTasks",
|
||||
mock.AnythingOfType("string"),
|
||||
mock.AnythingOfType("string"),
|
||||
mock.AnythingOfType("string"),
|
||||
mock.AnythingOfType("float64")).Return()
|
||||
|
||||
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
|
||||
return metricsMock
|
||||
@ -159,7 +163,11 @@ func TestSubmitMetrics(t *testing.T) {
|
||||
|
||||
t.Run("metrics recorded for API errors", func(t *testing.T) {
|
||||
metricsMock := setupMetricsMock()
|
||||
metricsMock.On("IncrementClientLongTasks", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return()
|
||||
metricsMock.On("IncrementClientLongTasks",
|
||||
mock.AnythingOfType("string"),
|
||||
mock.AnythingOfType("string"),
|
||||
mock.AnythingOfType("string"),
|
||||
mock.AnythingOfType("float64")).Return()
|
||||
|
||||
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
|
||||
return metricsMock
|
||||
@ -190,7 +198,11 @@ func TestSubmitMetrics(t *testing.T) {
|
||||
|
||||
t.Run("metrics recorded for URL length limit errors", func(t *testing.T) {
|
||||
metricsMock := setupMetricsMock()
|
||||
metricsMock.On("IncrementClientLongTasks", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return()
|
||||
metricsMock.On("IncrementClientLongTasks",
|
||||
mock.AnythingOfType("string"),
|
||||
mock.AnythingOfType("string"),
|
||||
mock.AnythingOfType("string"),
|
||||
mock.AnythingOfType("float64")).Return()
|
||||
|
||||
platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface {
|
||||
return metricsMock
|
||||
|
@ -3475,7 +3475,9 @@ func TestWebHubMembership(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebHubCloseConnOnDBFail(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableWebHubChannelIteration = true
|
||||
}).InitBasic()
|
||||
defer func() {
|
||||
th.TearDown()
|
||||
_, err := th.Server.Store().GetInternalMasterDB().Exec(`ALTER TABLE dummy RENAME to ChannelMembers`)
|
||||
|
@ -55,7 +55,7 @@ func (a *App) UpdateChannelBookmark(c request.CTX, updateBookmark *model.Channel
|
||||
isAnotherFile := updateBookmark.FileInfo != nil && updateBookmark.FileId != "" && updateBookmark.FileId != updateBookmark.FileInfo.Id
|
||||
|
||||
if isAnotherFile {
|
||||
if fileAlreadyAttachedErr := a.Srv().Store().ChannelBookmark().ErrorIfBookmarkFileInfoAlreadyAttached(updateBookmark.FileId); fileAlreadyAttachedErr != nil {
|
||||
if fileAlreadyAttachedErr := a.Srv().Store().ChannelBookmark().ErrorIfBookmarkFileInfoAlreadyAttached(updateBookmark.FileId, updateBookmark.ChannelId); fileAlreadyAttachedErr != nil {
|
||||
return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.update.app_error", nil, "", http.StatusInternalServerError).Wrap(fileAlreadyAttachedErr)
|
||||
}
|
||||
}
|
||||
|
@ -89,6 +89,7 @@ func TestUpdateBookmark(t *testing.T) {
|
||||
var testUpdateAnotherFile = func(th *TestHelper, t *testing.T) {
|
||||
file := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
@ -116,6 +117,7 @@ func TestUpdateBookmark(t *testing.T) {
|
||||
|
||||
file2 := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
@ -144,6 +146,106 @@ func TestUpdateBookmark(t *testing.T) {
|
||||
require.Nil(t, bookmarkResp)
|
||||
}
|
||||
|
||||
var testUpdateInvalidFiles = func(th *TestHelper, t *testing.T, creatingUserId string, updatingUserId string) {
|
||||
file := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
PreviewPath: "prevPath",
|
||||
Name: "test file",
|
||||
Extension: "png",
|
||||
MimeType: "images/png",
|
||||
Size: 873182,
|
||||
Width: 3076,
|
||||
Height: 2200,
|
||||
HasPreviewImage: true,
|
||||
}
|
||||
|
||||
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, file)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
err = th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, file.Id)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
th.Context.Session().UserId = creatingUserId
|
||||
|
||||
bookmark := createBookmark("File to be updated", model.ChannelBookmarkFile, th.BasicChannel.Id, file.Id)
|
||||
bookmarkToEdit, appErr := th.App.CreateChannelBookmark(th.Context, bookmark, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, bookmarkToEdit)
|
||||
|
||||
otherChannel := th.CreateChannel(th.Context, th.BasicTeam)
|
||||
|
||||
createAt := time.Now().Add(-1 * time.Minute)
|
||||
deleteAt := createAt.Add(1 * time.Second)
|
||||
|
||||
deletedFile := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
PreviewPath: "prevPath",
|
||||
Name: "test file",
|
||||
Extension: "png",
|
||||
MimeType: "images/png",
|
||||
Size: 873182,
|
||||
Width: 3076,
|
||||
Height: 2200,
|
||||
HasPreviewImage: true,
|
||||
CreateAt: createAt.UnixMilli(),
|
||||
UpdateAt: createAt.UnixMilli(),
|
||||
DeleteAt: deleteAt.UnixMilli(),
|
||||
}
|
||||
|
||||
_, err = th.App.Srv().Store().FileInfo().Save(th.Context, deletedFile)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
err = th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, deletedFile.Id)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
th.Context.Session().UserId = updatingUserId
|
||||
|
||||
updateBookmarkPending := bookmarkToEdit.Clone()
|
||||
updateBookmarkPending.FileId = deletedFile.Id
|
||||
bookmarkEdited, appErr := th.App.UpdateChannelBookmark(th.Context, updateBookmarkPending, "")
|
||||
assert.NotNil(t, appErr)
|
||||
require.Nil(t, bookmarkEdited)
|
||||
|
||||
anotherChannelFile := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: otherChannel.Id,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
PreviewPath: "prevPath",
|
||||
Name: "test file",
|
||||
Extension: "png",
|
||||
MimeType: "images/png",
|
||||
Size: 873182,
|
||||
Width: 3076,
|
||||
Height: 2200,
|
||||
HasPreviewImage: true,
|
||||
}
|
||||
|
||||
_, err = th.App.Srv().Store().FileInfo().Save(th.Context, anotherChannelFile)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
err = th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, anotherChannelFile.Id)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
updateBookmarkPending = bookmarkToEdit.Clone()
|
||||
updateBookmarkPending.FileId = anotherChannelFile.Id
|
||||
bookmarkEdited, appErr = th.App.UpdateChannelBookmark(th.Context, updateBookmarkPending, "")
|
||||
assert.NotNil(t, appErr)
|
||||
require.Nil(t, bookmarkEdited)
|
||||
}
|
||||
|
||||
t.Run("same user update a channel bookmark", func(t *testing.T) {
|
||||
bookmark1 := &model.ChannelBookmark{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
@ -166,6 +268,8 @@ func TestUpdateBookmark(t *testing.T) {
|
||||
assert.Greater(t, response.Updated.UpdateAt, response.Updated.CreateAt)
|
||||
|
||||
testUpdateAnotherFile(th, t)
|
||||
|
||||
testUpdateInvalidFiles(th, t, th.BasicUser.Id, th.BasicUser.Id)
|
||||
})
|
||||
|
||||
t.Run("another user update a channel bookmark", func(t *testing.T) {
|
||||
@ -181,6 +285,8 @@ func TestUpdateBookmark(t *testing.T) {
|
||||
assert.Equal(t, "New name", response.Deleted.DisplayName)
|
||||
|
||||
testUpdateAnotherFile(th, t)
|
||||
|
||||
testUpdateInvalidFiles(th, t, th.BasicUser.Id, th.BasicUser.Id)
|
||||
})
|
||||
|
||||
t.Run("update an already deleted channel bookmark", func(t *testing.T) {
|
||||
@ -265,6 +371,7 @@ func TestGetChannelBookmarks(t *testing.T) {
|
||||
|
||||
file := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
@ -346,6 +453,7 @@ func TestUpdateChannelBookmarkSortOrder(t *testing.T) {
|
||||
|
||||
file := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
|
@ -6,13 +6,16 @@ package app
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const CustomProfileAttributesFieldLimit = 20
|
||||
const (
|
||||
CustomProfileAttributesFieldLimit = 20
|
||||
)
|
||||
|
||||
var cpaGroupID string
|
||||
|
||||
@ -58,7 +61,6 @@ func (a *App) ListCPAFields() ([]*model.PropertyField, *model.AppError) {
|
||||
|
||||
opts := model.PropertyFieldSearchOpts{
|
||||
GroupID: groupID,
|
||||
Page: 0,
|
||||
PerPage: CustomProfileAttributesFieldLimit,
|
||||
}
|
||||
|
||||
@ -67,6 +69,10 @@ func (a *App) ListCPAFields() ([]*model.PropertyField, *model.AppError) {
|
||||
return nil, model.NewAppError("GetCPAFields", "app.custom_profile_attributes.search_property_fields.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
sort.Slice(fields, func(i, j int) bool {
|
||||
return model.CustomProfileAttributesPropertySortOrder(fields[i]) < model.CustomProfileAttributesPropertySortOrder(fields[j])
|
||||
})
|
||||
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
@ -76,12 +82,12 @@ func (a *App) CreateCPAField(field *model.PropertyField) (*model.PropertyField,
|
||||
return nil, model.NewAppError("CreateCPAField", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
existingFields, appErr := a.ListCPAFields()
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
fieldCount, err := a.Srv().propertyService.CountActivePropertyFieldsForGroup(groupID)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("CreateCPAField", "app.custom_profile_attributes.count_property_fields.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
if len(existingFields) >= CustomProfileAttributesFieldLimit {
|
||||
if fieldCount >= CustomProfileAttributesFieldLimit {
|
||||
return nil, model.NewAppError("CreateCPAField", "app.custom_profile_attributes.limit_reached.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err)
|
||||
}
|
||||
|
||||
@ -97,6 +103,10 @@ func (a *App) CreateCPAField(field *model.PropertyField) (*model.PropertyField,
|
||||
}
|
||||
}
|
||||
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldCreated, "", "", "", nil, "")
|
||||
message.Add("field", newField)
|
||||
a.Publish(message)
|
||||
|
||||
return newField, nil
|
||||
}
|
||||
|
||||
@ -122,6 +132,10 @@ func (a *App) PatchCPAField(fieldID string, patch *model.PropertyFieldPatch) (*m
|
||||
}
|
||||
}
|
||||
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldUpdated, "", "", "", nil, "")
|
||||
message.Add("field", patchedField)
|
||||
a.Publish(message)
|
||||
|
||||
return patchedField, nil
|
||||
}
|
||||
|
||||
@ -150,6 +164,10 @@ func (a *App) DeleteCPAField(id string) *model.AppError {
|
||||
}
|
||||
}
|
||||
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldDeleted, "", "", "", nil, "")
|
||||
message.Add("field_id", id)
|
||||
a.Publish(message)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -159,19 +177,16 @@ func (a *App) ListCPAValues(userID string) ([]*model.PropertyValue, *model.AppEr
|
||||
return nil, model.NewAppError("GetCPAFields", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
opts := model.PropertyValueSearchOpts{
|
||||
values, err := a.Srv().propertyService.SearchPropertyValues(model.PropertyValueSearchOpts{
|
||||
GroupID: groupID,
|
||||
TargetID: userID,
|
||||
Page: 0,
|
||||
PerPage: 999999,
|
||||
IncludeDeleted: false,
|
||||
}
|
||||
fields, err := a.Srv().propertyService.SearchPropertyValues(opts)
|
||||
PerPage: CustomProfileAttributesFieldLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("ListCPAValues", "app.custom_profile_attributes.list_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
return fields, nil
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (a *App) GetCPAValue(valueID string) (*model.PropertyValue, *model.AppError) {
|
||||
@ -193,11 +208,22 @@ func (a *App) GetCPAValue(valueID string) (*model.PropertyValue, *model.AppError
|
||||
}
|
||||
|
||||
func (a *App) PatchCPAValue(userID string, fieldID string, value json.RawMessage) (*model.PropertyValue, *model.AppError) {
|
||||
values, appErr := a.PatchCPAValues(userID, map[string]json.RawMessage{fieldID: value})
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
func (a *App) PatchCPAValues(userID string, fieldValueMap map[string]json.RawMessage) ([]*model.PropertyValue, *model.AppError) {
|
||||
groupID, err := a.cpaGroupID()
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("PatchCPAValues", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
valuesToUpdate := []*model.PropertyValue{}
|
||||
for fieldID, value := range fieldValueMap {
|
||||
// make sure field exists in this group
|
||||
existingField, appErr := a.GetCPAField(fieldID)
|
||||
if appErr != nil {
|
||||
@ -206,36 +232,30 @@ func (a *App) PatchCPAValue(userID string, fieldID string, value json.RawMessage
|
||||
return nil, model.NewAppError("PatchCPAValue", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
existingValues, appErr := a.ListCPAValues(userID)
|
||||
if appErr != nil {
|
||||
return nil, model.NewAppError("PatchCPAValue", "app.custom_profile_attributes.property_value_list.app_error", nil, "", http.StatusNotFound).Wrap(err)
|
||||
}
|
||||
var existingValue *model.PropertyValue
|
||||
for key, value := range existingValues {
|
||||
if value.FieldID == fieldID {
|
||||
existingValue = existingValues[key]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if existingValue != nil {
|
||||
existingValue.Value = value
|
||||
_, err = a.ch.srv.propertyService.UpdatePropertyValue(existingValue)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("PatchCPAValue", "app.custom_profile_attributes.property_value_update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
} else {
|
||||
propertyValue := &model.PropertyValue{
|
||||
value := &model.PropertyValue{
|
||||
GroupID: groupID,
|
||||
TargetType: "user",
|
||||
TargetID: userID,
|
||||
FieldID: fieldID,
|
||||
Value: value,
|
||||
}
|
||||
existingValue, err = a.ch.srv.propertyService.CreatePropertyValue(propertyValue)
|
||||
valuesToUpdate = append(valuesToUpdate, value)
|
||||
}
|
||||
|
||||
updatedValues, err := a.Srv().propertyService.UpsertPropertyValues(valuesToUpdate)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("PatchCPAValue", "app.custom_profile_attributes.property_value_creation.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return nil, model.NewAppError("PatchCPAValues", "app.custom_profile_attributes.property_value_upsert.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
updatedFieldValueMap := map[string]json.RawMessage{}
|
||||
for _, value := range updatedValues {
|
||||
updatedFieldValueMap[value.FieldID] = value.Value
|
||||
}
|
||||
return existingValue, nil
|
||||
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventCPAValuesUpdated, "", "", "", nil, "")
|
||||
message.Add("user_id", userID)
|
||||
message.Add("values", updatedFieldValueMap)
|
||||
a.Publish(message)
|
||||
|
||||
return updatedValues, nil
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ func TestGetCPAField(t *testing.T) {
|
||||
GroupID: cpaGroupID,
|
||||
Name: "Test Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
Attrs: map[string]any{"visibility": "hidden"},
|
||||
Attrs: model.StringInterface{"visibility": "hidden"},
|
||||
}
|
||||
|
||||
createdField, err := th.App.CreateCPAField(field)
|
||||
@ -76,13 +76,14 @@ func TestListCPAFields(t *testing.T) {
|
||||
require.NoError(t, cErr)
|
||||
|
||||
t.Run("should list the CPA property fields", func(t *testing.T) {
|
||||
field1 := &model.PropertyField{
|
||||
field1 := model.PropertyField{
|
||||
GroupID: cpaGroupID,
|
||||
Name: "Field 1",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsSortOrder: 1},
|
||||
}
|
||||
|
||||
_, err := th.App.Srv().propertyService.CreatePropertyField(field1)
|
||||
_, err := th.App.Srv().propertyService.CreatePropertyField(&field1)
|
||||
require.NoError(t, err)
|
||||
|
||||
field2 := &model.PropertyField{
|
||||
@ -93,23 +94,20 @@ func TestListCPAFields(t *testing.T) {
|
||||
_, err = th.App.Srv().propertyService.CreatePropertyField(field2)
|
||||
require.NoError(t, err)
|
||||
|
||||
field3 := &model.PropertyField{
|
||||
field3 := model.PropertyField{
|
||||
GroupID: cpaGroupID,
|
||||
Name: "Field 3",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsSortOrder: 0},
|
||||
}
|
||||
_, err = th.App.Srv().propertyService.CreatePropertyField(field3)
|
||||
_, err = th.App.Srv().propertyService.CreatePropertyField(&field3)
|
||||
require.NoError(t, err)
|
||||
|
||||
fields, appErr := th.App.ListCPAFields()
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, fields, 2)
|
||||
|
||||
fieldNames := []string{}
|
||||
for _, field := range fields {
|
||||
fieldNames = append(fieldNames, field.Name)
|
||||
}
|
||||
require.ElementsMatch(t, []string{"Field 1", "Field 3"}, fieldNames)
|
||||
require.Equal(t, "Field 3", fields[0].Name)
|
||||
require.Equal(t, "Field 1", fields[1].Name)
|
||||
})
|
||||
}
|
||||
|
||||
@ -146,7 +144,7 @@ func TestCreateCPAField(t *testing.T) {
|
||||
GroupID: cpaGroupID,
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
Attrs: map[string]any{"visibility": "hidden"},
|
||||
Attrs: model.StringInterface{"visibility": "hidden"},
|
||||
}
|
||||
|
||||
createdField, err := th.App.CreateCPAField(field)
|
||||
@ -226,14 +224,14 @@ func TestPatchCPAField(t *testing.T) {
|
||||
GroupID: cpaGroupID,
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
Attrs: map[string]any{"visibility": "hidden"},
|
||||
Attrs: model.StringInterface{"visibility": "hidden"},
|
||||
}
|
||||
createdField, err := th.App.CreateCPAField(newField)
|
||||
require.Nil(t, err)
|
||||
|
||||
patch := &model.PropertyFieldPatch{
|
||||
Name: model.NewPointer("Patched name"),
|
||||
Attrs: model.NewPointer(map[string]any{"visibility": "default"}),
|
||||
Attrs: model.NewPointer(model.StringInterface{"visibility": "default"}),
|
||||
TargetID: model.NewPointer(model.NewId()),
|
||||
TargetType: model.NewPointer(model.NewId()),
|
||||
}
|
||||
@ -517,3 +515,60 @@ func TestPatchCPAValue(t *testing.T) {
|
||||
require.Equal(t, userID, updatedValue.TargetID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestListCPAValues(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true")
|
||||
defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES")
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
cpaGroupID, cErr := th.App.cpaGroupID()
|
||||
require.NoError(t, cErr)
|
||||
|
||||
userID := model.NewId()
|
||||
|
||||
t.Run("should return empty list when user has no values", func(t *testing.T) {
|
||||
values, appErr := th.App.ListCPAValues(userID)
|
||||
require.Nil(t, appErr)
|
||||
require.Empty(t, values)
|
||||
})
|
||||
|
||||
t.Run("should list all values for a user", func(t *testing.T) {
|
||||
var expectedValues []json.RawMessage
|
||||
|
||||
for i := 1; i <= CustomProfileAttributesFieldLimit; i++ {
|
||||
field := &model.PropertyField{
|
||||
GroupID: cpaGroupID,
|
||||
Name: fmt.Sprintf("Field %d", i),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
}
|
||||
_, err := th.App.Srv().propertyService.CreatePropertyField(field)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := &model.PropertyValue{
|
||||
TargetID: userID,
|
||||
TargetType: "user",
|
||||
GroupID: cpaGroupID,
|
||||
FieldID: field.ID,
|
||||
Value: json.RawMessage(fmt.Sprintf(`"Value %d"`, i)),
|
||||
}
|
||||
_, err = th.App.Srv().propertyService.CreatePropertyValue(value)
|
||||
require.NoError(t, err)
|
||||
expectedValues = append(expectedValues, value.Value)
|
||||
}
|
||||
|
||||
// List values for original user
|
||||
values, appErr := th.App.ListCPAValues(userID)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, values, CustomProfileAttributesFieldLimit)
|
||||
|
||||
actualValues := make([]json.RawMessage, len(values))
|
||||
for i, value := range values {
|
||||
require.Equal(t, userID, value.TargetID)
|
||||
require.Equal(t, "user", value.TargetType)
|
||||
require.Equal(t, cpaGroupID, value.GroupID)
|
||||
actualValues[i] = value.Value
|
||||
}
|
||||
require.ElementsMatch(t, expectedValues, actualValues)
|
||||
})
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ func (a *App) RegisterPerformanceReport(rctx request.CTX, report *model.Performa
|
||||
for _, c := range report.Counters {
|
||||
switch c.Metric {
|
||||
case model.ClientLongTasks:
|
||||
a.Metrics().IncrementClientLongTasks(commonLabels["platform"], commonLabels["agent"], c.Value)
|
||||
a.Metrics().IncrementClientLongTasks(commonLabels["platform"], commonLabels["agent"], userID, c.Value)
|
||||
default:
|
||||
// we intentionally skip unknown metrics
|
||||
}
|
||||
@ -50,22 +50,26 @@ func (a *App) RegisterPerformanceReport(rctx request.CTX, report *model.Performa
|
||||
case model.ClientFirstContentfulPaint:
|
||||
a.Metrics().ObserveClientFirstContentfulPaint(commonLabels["platform"],
|
||||
commonLabels["agent"],
|
||||
userID,
|
||||
h.Value/1000)
|
||||
case model.ClientLargestContentfulPaint:
|
||||
a.Metrics().ObserveClientLargestContentfulPaint(
|
||||
commonLabels["platform"],
|
||||
commonLabels["agent"],
|
||||
h.GetLabelValue("region", model.AcceptedLCPRegions, "other"),
|
||||
userID,
|
||||
h.Value/1000)
|
||||
case model.ClientInteractionToNextPaint:
|
||||
a.Metrics().ObserveClientInteractionToNextPaint(
|
||||
commonLabels["platform"],
|
||||
commonLabels["agent"],
|
||||
h.GetLabelValue("interaction", model.AcceptedInteractions, "other"),
|
||||
userID,
|
||||
h.Value/1000)
|
||||
case model.ClientCumulativeLayoutShift:
|
||||
a.Metrics().ObserveClientCumulativeLayoutShift(commonLabels["platform"],
|
||||
commonLabels["agent"],
|
||||
userID,
|
||||
h.Value)
|
||||
case model.ClientPageLoadDuration:
|
||||
a.Metrics().ObserveClientPageLoadDuration(commonLabels["platform"],
|
||||
@ -76,20 +80,24 @@ func (a *App) RegisterPerformanceReport(rctx request.CTX, report *model.Performa
|
||||
commonLabels["platform"],
|
||||
commonLabels["agent"],
|
||||
h.GetLabelValue("fresh", model.AcceptedTrueFalseLabels, ""),
|
||||
userID,
|
||||
h.Value/1000)
|
||||
case model.ClientTeamSwitchDuration:
|
||||
a.Metrics().ObserveClientTeamSwitchDuration(
|
||||
commonLabels["platform"],
|
||||
commonLabels["agent"],
|
||||
h.GetLabelValue("fresh", model.AcceptedTrueFalseLabels, ""),
|
||||
userID,
|
||||
h.Value/1000)
|
||||
case model.ClientRHSLoadDuration:
|
||||
a.Metrics().ObserveClientRHSLoadDuration(commonLabels["platform"],
|
||||
commonLabels["agent"],
|
||||
userID,
|
||||
h.Value/1000)
|
||||
case model.ClientGlobalThreadsLoadDuration:
|
||||
a.Metrics().ObserveGlobalThreadsLoadDuration(commonLabels["platform"],
|
||||
commonLabels["agent"],
|
||||
userID,
|
||||
h.Value/1000)
|
||||
case model.MobileClientLoadDuration:
|
||||
a.Metrics().ObserveMobileClientLoadDuration(commonLabels["platform"],
|
||||
|
@ -27,6 +27,7 @@ import (
|
||||
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -95,6 +96,8 @@ type WebConn struct {
|
||||
UserId string
|
||||
PostedAck bool
|
||||
|
||||
allChannelMembers map[string]string
|
||||
lastAllChannelMembersTime int64
|
||||
lastUserActivityAt int64
|
||||
send chan model.WebSocketMessage
|
||||
// deadQueue behaves like a queue of a finite size
|
||||
@ -758,6 +761,8 @@ func (wc *WebConn) drainDeadQueue(index int) error {
|
||||
|
||||
// InvalidateCache resets all internal data of the WebConn.
|
||||
func (wc *WebConn) InvalidateCache() {
|
||||
wc.allChannelMembers = nil
|
||||
wc.lastAllChannelMembersTime = 0
|
||||
wc.SetSession(nil)
|
||||
wc.SetSessionExpiresAt(0)
|
||||
}
|
||||
@ -938,11 +943,38 @@ func (wc *WebConn) ShouldSendEvent(msg *model.WebSocketEvent) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
if *wc.Platform.Config().ServiceSettings.EnableWebHubChannelIteration {
|
||||
// We don't need to do any further checks because this is already scoped
|
||||
// to channel members from web_hub.
|
||||
return true
|
||||
}
|
||||
|
||||
if model.GetMillis()-wc.lastAllChannelMembersTime > webConnMemberCacheTime {
|
||||
wc.allChannelMembers = nil
|
||||
wc.lastAllChannelMembersTime = 0
|
||||
}
|
||||
|
||||
if wc.allChannelMembers == nil {
|
||||
result, err := wc.Platform.Store.Channel().GetAllChannelMembersForUser(
|
||||
sqlstore.RequestContextWithMaster(request.EmptyContext(wc.Platform.logger)),
|
||||
wc.UserId,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
mlog.Error("webhub.shouldSendEvent.", mlog.Err(err))
|
||||
return false
|
||||
}
|
||||
wc.allChannelMembers = result
|
||||
wc.lastAllChannelMembersTime = model.GetMillis()
|
||||
}
|
||||
|
||||
if _, ok := wc.allChannelMembers[chID]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Only report events to users who are in the team for the event
|
||||
if msg.GetBroadcast().TeamId != "" {
|
||||
return wc.isMemberOfTeam(msg.GetBroadcast().TeamId)
|
||||
|
@ -486,6 +486,7 @@ func (h *Hub) Start() {
|
||||
connIndex := newHubConnectionIndex(inactiveConnReaperInterval,
|
||||
h.platform.Store,
|
||||
h.platform.logger,
|
||||
*h.platform.Config().ServiceSettings.EnableWebHubChannelIteration,
|
||||
)
|
||||
|
||||
for {
|
||||
@ -599,6 +600,11 @@ func (h *Hub) Start() {
|
||||
for _, webConn := range connIndex.ForUser(userID) {
|
||||
webConn.InvalidateCache()
|
||||
}
|
||||
|
||||
if !*h.platform.Config().ServiceSettings.EnableWebHubChannelIteration {
|
||||
continue
|
||||
}
|
||||
|
||||
err := connIndex.InvalidateCMCacheForUser(userID)
|
||||
if err != nil {
|
||||
h.platform.Log().Error("Error while invalidating channel member cache", mlog.String("user_id", userID), mlog.Err(err))
|
||||
@ -666,7 +672,7 @@ func (h *Hub) Start() {
|
||||
}
|
||||
} else if userID := msg.GetBroadcast().UserId; userID != "" {
|
||||
targetConns = connIndex.ForUser(userID)
|
||||
} else if channelID := msg.GetBroadcast().ChannelId; channelID != "" {
|
||||
} else if channelID := msg.GetBroadcast().ChannelId; channelID != "" && *h.platform.Config().ServiceSettings.EnableWebHubChannelIteration {
|
||||
targetConns = connIndex.ForChannel(channelID)
|
||||
}
|
||||
if targetConns != nil {
|
||||
@ -733,6 +739,10 @@ func closeAndRemoveConn(connIndex *hubConnectionIndex, conn *WebConn) {
|
||||
connIndex.Remove(conn)
|
||||
}
|
||||
|
||||
type connMetadata struct {
|
||||
channelIDs []string
|
||||
}
|
||||
|
||||
// hubConnectionIndex provides fast addition, removal, and iteration of web connections.
|
||||
// It requires 4 functionalities which need to be very fast:
|
||||
// - check if a connection exists or not.
|
||||
@ -740,18 +750,19 @@ func closeAndRemoveConn(connIndex *hubConnectionIndex, conn *WebConn) {
|
||||
// - get all connections for a given channelID.
|
||||
// - get all connections.
|
||||
type hubConnectionIndex struct {
|
||||
// byUserId stores the list of connections for a given userID
|
||||
byUserId map[string][]*WebConn
|
||||
// byChannelID stores the list of connections for a given channelID.
|
||||
byChannelID map[string][]*WebConn
|
||||
// byConnection serves the dual purpose of storing the index of the webconn
|
||||
// in the value of byUserId map, and also to get all connections.
|
||||
byConnection map[*WebConn]int
|
||||
// byUserId stores the set of connections for a given userID
|
||||
byUserId map[string]map[*WebConn]struct{}
|
||||
// byChannelID stores the set of connections for a given channelID
|
||||
byChannelID map[string]map[*WebConn]struct{}
|
||||
// byConnection serves the dual purpose of storing the channelIDs
|
||||
// and also to get all connections
|
||||
byConnection map[*WebConn]connMetadata
|
||||
byConnectionId map[string]*WebConn
|
||||
// staleThreshold is the limit beyond which inactive connections
|
||||
// will be deleted.
|
||||
staleThreshold time.Duration
|
||||
|
||||
fastIteration bool
|
||||
store store.Store
|
||||
logger mlog.LoggerIFace
|
||||
}
|
||||
@ -759,71 +770,75 @@ type hubConnectionIndex struct {
|
||||
func newHubConnectionIndex(interval time.Duration,
|
||||
store store.Store,
|
||||
logger mlog.LoggerIFace,
|
||||
fastIteration bool,
|
||||
) *hubConnectionIndex {
|
||||
return &hubConnectionIndex{
|
||||
byUserId: make(map[string][]*WebConn),
|
||||
byChannelID: make(map[string][]*WebConn),
|
||||
byConnection: make(map[*WebConn]int),
|
||||
byUserId: make(map[string]map[*WebConn]struct{}),
|
||||
byChannelID: make(map[string]map[*WebConn]struct{}),
|
||||
byConnection: make(map[*WebConn]connMetadata),
|
||||
byConnectionId: make(map[string]*WebConn),
|
||||
staleThreshold: interval,
|
||||
store: store,
|
||||
logger: logger,
|
||||
fastIteration: fastIteration,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *hubConnectionIndex) Add(wc *WebConn) error {
|
||||
var channelIDs []string
|
||||
if i.fastIteration {
|
||||
cm, err := i.store.Channel().GetAllChannelMembersForUser(request.EmptyContext(i.logger), wc.UserId, false, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getChannelMembersForUser: %v", err)
|
||||
}
|
||||
|
||||
// Store channel IDs and add to byChannelID
|
||||
channelIDs = make([]string, 0, len(cm))
|
||||
for chID := range cm {
|
||||
i.byChannelID[chID] = append(i.byChannelID[chID], wc)
|
||||
channelIDs = append(channelIDs, chID)
|
||||
|
||||
// Initialize the channel's map if it doesn't exist
|
||||
if _, ok := i.byChannelID[chID]; !ok {
|
||||
i.byChannelID[chID] = make(map[*WebConn]struct{})
|
||||
}
|
||||
i.byChannelID[chID][wc] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
i.byUserId[wc.UserId] = append(i.byUserId[wc.UserId], wc)
|
||||
i.byConnection[wc] = len(i.byUserId[wc.UserId]) - 1
|
||||
// Initialize the user's map if it doesn't exist
|
||||
if _, ok := i.byUserId[wc.UserId]; !ok {
|
||||
i.byUserId[wc.UserId] = make(map[*WebConn]struct{})
|
||||
}
|
||||
i.byUserId[wc.UserId][wc] = struct{}{}
|
||||
i.byConnection[wc] = connMetadata{
|
||||
channelIDs: channelIDs,
|
||||
}
|
||||
i.byConnectionId[wc.GetConnectionID()] = wc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *hubConnectionIndex) Remove(wc *WebConn) {
|
||||
userConnIndex, ok := i.byConnection[wc]
|
||||
connMeta, ok := i.byConnection[wc]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the wc from i.byUserId
|
||||
// get the conn slice.
|
||||
userConnections := i.byUserId[wc.UserId]
|
||||
// get the last connection.
|
||||
last := userConnections[len(userConnections)-1]
|
||||
// https://go.dev/wiki/SliceTricks#delete-without-preserving-order
|
||||
userConnections[userConnIndex] = last
|
||||
userConnections[len(userConnections)-1] = nil
|
||||
i.byUserId[wc.UserId] = userConnections[:len(userConnections)-1]
|
||||
// set the index of the connection that was moved to the new index.
|
||||
i.byConnection[last] = userConnIndex
|
||||
// Remove from byUserId
|
||||
if userConns, ok := i.byUserId[wc.UserId]; ok {
|
||||
delete(userConns, wc)
|
||||
}
|
||||
|
||||
connectionID := wc.GetConnectionID()
|
||||
// Remove webconns from i.byChannelID
|
||||
// This has O(n) complexity. We are trading off speed while removing
|
||||
// a connection, to improve broadcasting a message.
|
||||
for chID, webConns := range i.byChannelID {
|
||||
// https://go.dev/wiki/SliceTricks#filtering-without-allocating
|
||||
filtered := webConns[:0]
|
||||
for _, conn := range webConns {
|
||||
if conn.GetConnectionID() != connectionID {
|
||||
filtered = append(filtered, conn)
|
||||
if i.fastIteration {
|
||||
// Remove from byChannelID for each channel
|
||||
for _, chID := range connMeta.channelIDs {
|
||||
if channelConns, ok := i.byChannelID[chID]; ok {
|
||||
delete(channelConns, wc)
|
||||
}
|
||||
}
|
||||
for i := len(filtered); i < len(webConns); i++ {
|
||||
webConns[i] = nil
|
||||
}
|
||||
i.byChannelID[chID] = filtered
|
||||
}
|
||||
|
||||
delete(i.byConnection, wc)
|
||||
delete(i.byConnectionId, connectionID)
|
||||
delete(i.byConnectionId, wc.GetConnectionID())
|
||||
}
|
||||
|
||||
func (i *hubConnectionIndex) InvalidateCMCacheForUser(userID string) error {
|
||||
@ -833,25 +848,40 @@ func (i *hubConnectionIndex) InvalidateCMCacheForUser(userID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear out all user entries which belong to channels.
|
||||
for chID, webConns := range i.byChannelID {
|
||||
// https://go.dev/wiki/SliceTricks#filtering-without-allocating
|
||||
filtered := webConns[:0]
|
||||
for _, conn := range webConns {
|
||||
if conn.UserId != userID {
|
||||
filtered = append(filtered, conn)
|
||||
// Get all connections for this user
|
||||
conns := i.ForUser(userID)
|
||||
|
||||
// Remove all user connections from existing channels
|
||||
for _, conn := range conns {
|
||||
if meta, ok := i.byConnection[conn]; ok {
|
||||
// Remove from old channels
|
||||
for _, chID := range meta.channelIDs {
|
||||
if channelConns, ok := i.byChannelID[chID]; ok {
|
||||
delete(channelConns, conn)
|
||||
}
|
||||
}
|
||||
for i := len(filtered); i < len(webConns); i++ {
|
||||
webConns[i] = nil
|
||||
}
|
||||
i.byChannelID[chID] = filtered
|
||||
}
|
||||
|
||||
// re-populate the cache
|
||||
// Add connections to new channels
|
||||
for _, conn := range conns {
|
||||
newChannelIDs := make([]string, 0, len(cm))
|
||||
for chID := range cm {
|
||||
i.byChannelID[chID] = append(i.byChannelID[chID], i.ForUser(userID)...)
|
||||
newChannelIDs = append(newChannelIDs, chID)
|
||||
// Initialize channel map if needed
|
||||
if _, ok := i.byChannelID[chID]; !ok {
|
||||
i.byChannelID[chID] = make(map[*WebConn]struct{})
|
||||
}
|
||||
i.byChannelID[chID][conn] = struct{}{}
|
||||
}
|
||||
|
||||
// Update connection metadata
|
||||
if meta, ok := i.byConnection[conn]; ok {
|
||||
meta.channelIDs = newChannelIDs
|
||||
i.byConnection[conn] = meta
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -862,26 +892,31 @@ func (i *hubConnectionIndex) Has(wc *WebConn) bool {
|
||||
|
||||
// ForUser returns all connections for a user ID.
|
||||
func (i *hubConnectionIndex) ForUser(id string) []*WebConn {
|
||||
// Fast path if there is only one or fewer connection.
|
||||
if len(i.byUserId[id]) <= 1 {
|
||||
return i.byUserId[id]
|
||||
userConns, ok := i.byUserId[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Move to using maps.Keys to use the iterator pattern with 1.23.
|
||||
// This saves the additional slice copy.
|
||||
conns := make([]*WebConn, 0, len(userConns))
|
||||
for conn := range userConns {
|
||||
conns = append(conns, conn)
|
||||
}
|
||||
// If there are multiple connections per user,
|
||||
// then we have to return a clone of the slice
|
||||
// to allow connIndex.Remove to be safely called while
|
||||
// iterating the slice.
|
||||
conns := make([]*WebConn, len(i.byUserId[id]))
|
||||
copy(conns, i.byUserId[id])
|
||||
return conns
|
||||
}
|
||||
|
||||
// ForChannel returns all connections for a channelID.
|
||||
func (i *hubConnectionIndex) ForChannel(channelID string) []*WebConn {
|
||||
// Note: this is expensive because usually there will be
|
||||
// more than 1 member for a channel, and broadcasting
|
||||
// is a hot path, but worth it.
|
||||
conns := make([]*WebConn, len(i.byChannelID[channelID]))
|
||||
copy(conns, i.byChannelID[channelID])
|
||||
channelConns, ok := i.byChannelID[channelID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
conns := make([]*WebConn, 0, len(channelConns))
|
||||
for conn := range channelConns {
|
||||
conns = append(conns, conn)
|
||||
}
|
||||
return conns
|
||||
}
|
||||
|
||||
@ -902,7 +937,7 @@ func (i *hubConnectionIndex) ForConnection(id string) *WebConn {
|
||||
}
|
||||
|
||||
// All returns the full webConn index.
|
||||
func (i *hubConnectionIndex) All() map[*WebConn]int {
|
||||
func (i *hubConnectionIndex) All() map[*WebConn]connMetadata {
|
||||
return i.byConnection
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,7 @@ package platform
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -197,8 +198,10 @@ func TestHubConnIndex(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, fastIterate := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("fastIterate=%t", fastIterate), func(t *testing.T) {
|
||||
t.Run("Basic", func(t *testing.T) {
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger)
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, fastIterate)
|
||||
|
||||
// User1
|
||||
wc1 := &WebConn{
|
||||
@ -285,7 +288,7 @@ func TestHubConnIndex(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ByConnectionId", func(t *testing.T) {
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger)
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, fastIterate)
|
||||
|
||||
// User1
|
||||
wc1ID := model.NewId()
|
||||
@ -342,9 +345,11 @@ func TestHubConnIndex(t *testing.T) {
|
||||
assert.Equal(t, (*WebConn)(nil), connIndex.ForConnection(wc2ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("ByChannelId", func(t *testing.T) {
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger)
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, true)
|
||||
|
||||
// User1
|
||||
wc1ID := model.NewId()
|
||||
@ -414,7 +419,7 @@ func TestHubConnIndexIncorrectRemoval(t *testing.T) {
|
||||
th := Setup(t)
|
||||
defer th.TearDown()
|
||||
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger)
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
|
||||
|
||||
// User2
|
||||
wc2 := &WebConn{
|
||||
@ -461,7 +466,7 @@ func TestHubConnIndexInactive(t *testing.T) {
|
||||
th := Setup(t)
|
||||
defer th.TearDown()
|
||||
|
||||
connIndex := newHubConnectionIndex(2*time.Second, th.Service.Store, th.Service.logger)
|
||||
connIndex := newHubConnectionIndex(2*time.Second, th.Service.Store, th.Service.logger, false)
|
||||
|
||||
// User1
|
||||
wc1 := &WebConn{
|
||||
@ -621,7 +626,7 @@ func TestHubWebConnCount(t *testing.T) {
|
||||
func BenchmarkHubConnIndex(b *testing.B) {
|
||||
th := Setup(b).InitBasic()
|
||||
defer th.TearDown()
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger)
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
|
||||
|
||||
// User1
|
||||
wc1 := &WebConn{
|
||||
@ -666,7 +671,7 @@ func TestHubConnIndexRemoveMemLeak(t *testing.T) {
|
||||
th := Setup(t)
|
||||
defer th.TearDown()
|
||||
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger)
|
||||
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
|
||||
|
||||
wc := &WebConn{
|
||||
Platform: th.Service,
|
||||
|
@ -19,6 +19,10 @@ func (ps *PropertyService) GetPropertyFields(ids []string) ([]*model.PropertyFie
|
||||
return ps.fieldStore.GetMany(ids)
|
||||
}
|
||||
|
||||
func (ps *PropertyService) CountActivePropertyFieldsForGroup(groupID string) (int64, error) {
|
||||
return ps.fieldStore.CountForGroup(groupID, false)
|
||||
}
|
||||
|
||||
func (ps *PropertyService) SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) {
|
||||
return ps.fieldStore.SearchPropertyFields(opts)
|
||||
}
|
||||
|
@ -36,6 +36,19 @@ func (ps *PropertyService) UpdatePropertyValues(values []*model.PropertyValue) (
|
||||
return ps.valueStore.Update(values)
|
||||
}
|
||||
|
||||
func (ps *PropertyService) UpsertPropertyValue(value *model.PropertyValue) (*model.PropertyValue, error) {
|
||||
values, err := ps.UpsertPropertyValues([]*model.PropertyValue{value})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
func (ps *PropertyService) UpsertPropertyValues(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
return ps.valueStore.Upsert(values)
|
||||
}
|
||||
|
||||
func (ps *PropertyService) DeletePropertyValue(id string) error {
|
||||
return ps.valueStore.Delete(id)
|
||||
}
|
||||
|
@ -257,6 +257,10 @@ channels/db/migrations/mysql/000129_add_property_system_architecture.down.sql
|
||||
channels/db/migrations/mysql/000129_add_property_system_architecture.up.sql
|
||||
channels/db/migrations/mysql/000130_system_console_stats.down.sql
|
||||
channels/db/migrations/mysql/000130_system_console_stats.up.sql
|
||||
channels/db/migrations/mysql/000131_create_index_pagination_on_property_values.down.sql
|
||||
channels/db/migrations/mysql/000131_create_index_pagination_on_property_values.up.sql
|
||||
channels/db/migrations/mysql/000132_create_index_pagination_on_property_fields.down.sql
|
||||
channels/db/migrations/mysql/000132_create_index_pagination_on_property_fields.up.sql
|
||||
channels/db/migrations/postgres/000001_create_teams.down.sql
|
||||
channels/db/migrations/postgres/000001_create_teams.up.sql
|
||||
channels/db/migrations/postgres/000002_create_team_members.down.sql
|
||||
@ -515,3 +519,7 @@ channels/db/migrations/postgres/000129_add_property_system_architecture.down.sql
|
||||
channels/db/migrations/postgres/000129_add_property_system_architecture.up.sql
|
||||
channels/db/migrations/postgres/000130_system_console_stats.down.sql
|
||||
channels/db/migrations/postgres/000130_system_console_stats.up.sql
|
||||
channels/db/migrations/postgres/000131_create_index_pagination_on_property_values.down.sql
|
||||
channels/db/migrations/postgres/000131_create_index_pagination_on_property_values.up.sql
|
||||
channels/db/migrations/postgres/000132_create_index_pagination_on_property_fields.down.sql
|
||||
channels/db/migrations/postgres/000132_create_index_pagination_on_property_fields.up.sql
|
||||
|
@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_name = 'PropertyValues'
|
||||
AND table_schema = DATABASE()
|
||||
AND index_name = 'idx_propertyvalues_create_at_id'
|
||||
) > 0,
|
||||
'DROP INDEX idx_propertyvalues_create_at_id ON PropertyValues;',
|
||||
'SELECT 1'
|
||||
));
|
||||
|
||||
PREPARE removeIndexIfExists FROM @preparedStatement;
|
||||
EXECUTE removeIndexIfExists;
|
||||
DEALLOCATE PREPARE removeIndexIfExists;
|
@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_name = 'PropertyValues'
|
||||
AND table_schema = DATABASE()
|
||||
AND index_name = 'idx_propertyvalues_create_at_id'
|
||||
) > 0,
|
||||
'SELECT 1',
|
||||
'CREATE INDEX idx_propertyvalues_create_at_id ON PropertyValues(CreateAt, ID);'
|
||||
));
|
||||
|
||||
PREPARE createIndexIfNotExists FROM @preparedStatement;
|
||||
EXECUTE createIndexIfNotExists;
|
||||
DEALLOCATE PREPARE createIndexIfNotExists;
|
@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_name = 'PropertyFields'
|
||||
AND table_schema = DATABASE()
|
||||
AND index_name = 'idx_propertyfields_create_at_id'
|
||||
) > 0,
|
||||
'DROP INDEX idx_propertyfields_create_at_id ON PropertyFields;',
|
||||
'SELECT 1'
|
||||
));
|
||||
|
||||
PREPARE removeIndexIfExists FROM @preparedStatement;
|
||||
EXECUTE removeIndexIfExists;
|
||||
DEALLOCATE PREPARE removeIndexIfExists;
|
@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_name = 'PropertyFields'
|
||||
AND table_schema = DATABASE()
|
||||
AND index_name = 'idx_propertyfields_create_at_id'
|
||||
) > 0,
|
||||
'SELECT 1',
|
||||
'CREATE INDEX idx_propertyfields_create_at_id ON PropertyFields(CreateAt, ID);'
|
||||
));
|
||||
|
||||
PREPARE createIndexIfNotExists FROM @preparedStatement;
|
||||
EXECUTE createIndexIfNotExists;
|
||||
DEALLOCATE PREPARE createIndexIfNotExists;
|
@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_propertyvalues_create_at_id;
|
@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_propertyvalues_create_at_id ON PropertyValues(CreateAt, ID)
|
@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_propertyfields_create_at_id;
|
@ -0,0 +1,2 @@
|
||||
-- morph:nontransactional
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_propertyfields_create_at_id ON PropertyFields(CreateAt, ID)
|
@ -3153,11 +3153,11 @@ func (s *RetryLayerChannelBookmarkStore) Delete(bookmarkID string, deleteFile bo
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileID string) error {
|
||||
func (s *RetryLayerChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileID string, channelID string) error {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
err := s.ChannelBookmarkStore.ErrorIfBookmarkFileInfoAlreadyAttached(fileID)
|
||||
err := s.ChannelBookmarkStore.ErrorIfBookmarkFileInfoAlreadyAttached(fileID, channelID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@ -8949,6 +8949,27 @@ func (s *RetryLayerProductNoticesStore) View(userID string, notices []string) er
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPropertyFieldStore) CountForGroup(groupID string, includeDeleted bool) (int64, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.PropertyFieldStore.CountForGroup(groupID, includeDeleted)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return result, err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return result, err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPropertyFieldStore) Create(field *model.PropertyField) (*model.PropertyField, error) {
|
||||
|
||||
tries := 0
|
||||
@ -9054,11 +9075,11 @@ func (s *RetryLayerPropertyFieldStore) SearchPropertyFields(opts model.PropertyF
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPropertyFieldStore) Update(field []*model.PropertyField) ([]*model.PropertyField, error) {
|
||||
func (s *RetryLayerPropertyFieldStore) Update(fields []*model.PropertyField) ([]*model.PropertyField, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.PropertyFieldStore.Update(field)
|
||||
result, err := s.PropertyFieldStore.Update(fields)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
@ -9243,11 +9264,32 @@ func (s *RetryLayerPropertyValueStore) SearchPropertyValues(opts model.PropertyV
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPropertyValueStore) Update(field []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
func (s *RetryLayerPropertyValueStore) Update(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.PropertyValueStore.Update(field)
|
||||
result, err := s.PropertyValueStore.Update(values)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return result, err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return result, err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPropertyValueStore) Upsert(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.PropertyValueStore.Upsert(values)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ func bookmarkWithFileInfoSliceColumns() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SqlChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileId string) error {
|
||||
func (s *SqlChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileId string, channelId string) error {
|
||||
existingQuery := s.getSubQueryBuilder().
|
||||
Select("FileInfoId").
|
||||
From("ChannelBookmarks").
|
||||
@ -66,11 +66,13 @@ func (s *SqlChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileId
|
||||
Where(sq.Or{
|
||||
sq.Expr("Id IN (?)", existingQuery),
|
||||
sq.And{
|
||||
sq.Eq{"Id": fileId},
|
||||
sq.Or{
|
||||
sq.NotEq{"PostId": ""},
|
||||
sq.NotEq{"CreatorId": model.BookmarkFileOwner},
|
||||
sq.NotEq{"ChannelId": channelId},
|
||||
sq.NotEq{"DeleteAt": 0},
|
||||
},
|
||||
sq.Eq{"Id": fileId},
|
||||
},
|
||||
})
|
||||
|
||||
@ -139,7 +141,7 @@ func (s *SqlChannelBookmarkStore) Save(bookmark *model.ChannelBookmark, increase
|
||||
}
|
||||
|
||||
if bookmark.FileId != "" {
|
||||
err = s.ErrorIfBookmarkFileInfoAlreadyAttached(bookmark.FileId)
|
||||
err = s.ErrorIfBookmarkFileInfoAlreadyAttached(bookmark.FileId, bookmark.ChannelId)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable_to_save_channel_bookmark")
|
||||
}
|
||||
|
@ -78,9 +78,26 @@ func (s *SqlPropertyFieldStore) GetMany(ids []string) ([]*model.PropertyField, e
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
func (s *SqlPropertyFieldStore) CountForGroup(groupID string, includeDeleted bool) (int64, error) {
|
||||
var count int64
|
||||
builder := s.getQueryBuilder().
|
||||
Select("COUNT(id)").
|
||||
From("PropertyFields").
|
||||
Where(sq.Eq{"GroupID": groupID})
|
||||
|
||||
if !includeDeleted {
|
||||
builder = builder.Where(sq.Eq{"DeleteAt": 0})
|
||||
}
|
||||
|
||||
if err := s.GetReplica().GetBuilder(&count, builder); err != nil {
|
||||
return int64(0), errors.Wrap(err, "failed to count Sessions")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *SqlPropertyFieldStore) SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) {
|
||||
if opts.Page < 0 {
|
||||
return nil, errors.New("page must be positive integer")
|
||||
if err := opts.Cursor.IsValid(); err != nil {
|
||||
return nil, fmt.Errorf("cursor is invalid: %w", err)
|
||||
}
|
||||
|
||||
if opts.PerPage < 1 {
|
||||
@ -88,10 +105,19 @@ func (s *SqlPropertyFieldStore) SearchPropertyFields(opts model.PropertyFieldSea
|
||||
}
|
||||
|
||||
builder := s.tableSelectQuery.
|
||||
OrderBy("CreateAt ASC").
|
||||
Offset(uint64(opts.Page * opts.PerPage)).
|
||||
OrderBy("CreateAt ASC, Id ASC").
|
||||
Limit(uint64(opts.PerPage))
|
||||
|
||||
if !opts.Cursor.IsEmpty() {
|
||||
builder = builder.Where(sq.Or{
|
||||
sq.Gt{"CreateAt": opts.Cursor.CreateAt},
|
||||
sq.And{
|
||||
sq.Eq{"CreateAt": opts.Cursor.CreateAt},
|
||||
sq.Gt{"Id": opts.Cursor.PropertyFieldID},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if !opts.IncludeDeleted {
|
||||
builder = builder.Where(sq.Eq{"DeleteAt": 0})
|
||||
}
|
||||
@ -128,44 +154,66 @@ func (s *SqlPropertyFieldStore) Update(fields []*model.PropertyField) (_ []*mode
|
||||
defer finalizeTransactionX(transaction, &err)
|
||||
|
||||
updateTime := model.GetMillis()
|
||||
for _, field := range fields {
|
||||
field.UpdateAt = updateTime
|
||||
isPostgres := s.DriverName() == model.DatabaseDriverPostgres
|
||||
nameCase := sq.Case("id")
|
||||
typeCase := sq.Case("id")
|
||||
attrsCase := sq.Case("id")
|
||||
targetIDCase := sq.Case("id")
|
||||
targetTypeCase := sq.Case("id")
|
||||
deleteAtCase := sq.Case("id")
|
||||
ids := make([]string, len(fields))
|
||||
|
||||
for i, field := range fields {
|
||||
field.UpdateAt = updateTime
|
||||
if vErr := field.IsValid(); vErr != nil {
|
||||
return nil, errors.Wrap(vErr, "property_field_update_isvalid")
|
||||
}
|
||||
|
||||
queryString, args, err := s.getQueryBuilder().
|
||||
Update("PropertyFields").
|
||||
Set("Name", field.Name).
|
||||
Set("Type", field.Type).
|
||||
Set("Attrs", field.Attrs).
|
||||
Set("TargetID", field.TargetID).
|
||||
Set("TargetType", field.TargetType).
|
||||
Set("UpdateAt", field.UpdateAt).
|
||||
Set("DeleteAt", field.DeleteAt).
|
||||
Where(sq.Eq{"id": field.ID}).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "property_field_update_tosql")
|
||||
ids[i] = field.ID
|
||||
whenID := sq.Expr("?", field.ID)
|
||||
if isPostgres {
|
||||
nameCase = nameCase.When(whenID, sq.Expr("?::text", field.Name))
|
||||
typeCase = typeCase.When(whenID, sq.Expr("?::property_field_type", field.Type))
|
||||
attrsCase = attrsCase.When(whenID, sq.Expr("?::jsonb", field.Attrs))
|
||||
targetIDCase = targetIDCase.When(whenID, sq.Expr("?::text", field.TargetID))
|
||||
targetTypeCase = targetTypeCase.When(whenID, sq.Expr("?::text", field.TargetType))
|
||||
deleteAtCase = deleteAtCase.When(whenID, sq.Expr("?::bigint", field.DeleteAt))
|
||||
} else {
|
||||
nameCase = nameCase.When(whenID, sq.Expr("?", field.Name))
|
||||
typeCase = typeCase.When(whenID, sq.Expr("?", field.Type))
|
||||
attrsCase = attrsCase.When(whenID, sq.Expr("?", field.Attrs))
|
||||
targetIDCase = targetIDCase.When(whenID, sq.Expr("?", field.TargetID))
|
||||
targetTypeCase = targetTypeCase.When(whenID, sq.Expr("?", field.TargetType))
|
||||
deleteAtCase = deleteAtCase.When(whenID, sq.Expr("?", field.DeleteAt))
|
||||
}
|
||||
}
|
||||
|
||||
result, err := transaction.Exec(queryString, args...)
|
||||
builder := s.getQueryBuilder().
|
||||
Update("PropertyFields").
|
||||
Set("Name", nameCase).
|
||||
Set("Type", typeCase).
|
||||
Set("Attrs", attrsCase).
|
||||
Set("TargetID", targetIDCase).
|
||||
Set("TargetType", targetTypeCase).
|
||||
Set("UpdateAt", updateTime).
|
||||
Set("DeleteAt", deleteAtCase).
|
||||
Where(sq.Eq{"id": ids})
|
||||
|
||||
result, err := transaction.ExecBuilder(builder)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to update property field with id: %s", field.ID)
|
||||
return nil, errors.Wrap(err, "property_field_update_exec")
|
||||
}
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "property_field_update_rowsaffected")
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, store.NewErrNotFound("PropertyField", field.ID)
|
||||
}
|
||||
if count != int64(len(fields)) {
|
||||
return nil, errors.Errorf("failed to update, some property fields were not found, got %d of %d", count, len(fields))
|
||||
}
|
||||
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return nil, errors.Wrap(err, "property_field_update_commit")
|
||||
return nil, errors.Wrap(err, "property_field_update_commit_transaction")
|
||||
}
|
||||
|
||||
return fields, nil
|
||||
|
@ -83,8 +83,8 @@ func (s *SqlPropertyValueStore) GetMany(ids []string) ([]*model.PropertyValue, e
|
||||
}
|
||||
|
||||
func (s *SqlPropertyValueStore) SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) {
|
||||
if opts.Page < 0 {
|
||||
return nil, errors.New("page must be positive integer")
|
||||
if err := opts.Cursor.IsValid(); err != nil {
|
||||
return nil, fmt.Errorf("cursor is invalid: %w", err)
|
||||
}
|
||||
|
||||
if opts.PerPage < 1 {
|
||||
@ -92,10 +92,19 @@ func (s *SqlPropertyValueStore) SearchPropertyValues(opts model.PropertyValueSea
|
||||
}
|
||||
|
||||
builder := s.tableSelectQuery.
|
||||
OrderBy("CreateAt ASC").
|
||||
Offset(uint64(opts.Page * opts.PerPage)).
|
||||
OrderBy("CreateAt ASC, Id ASC").
|
||||
Limit(uint64(opts.PerPage))
|
||||
|
||||
if !opts.Cursor.IsEmpty() {
|
||||
builder = builder.Where(sq.Or{
|
||||
sq.Gt{"CreateAt": opts.Cursor.CreateAt},
|
||||
sq.And{
|
||||
sq.Eq{"CreateAt": opts.Cursor.CreateAt},
|
||||
sq.Gt{"Id": opts.Cursor.PropertyValueID},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if !opts.IncludeDeleted {
|
||||
builder = builder.Where(sq.Eq{"DeleteAt": 0})
|
||||
}
|
||||
@ -136,11 +145,78 @@ func (s *SqlPropertyValueStore) Update(values []*model.PropertyValue) (_ []*mode
|
||||
defer finalizeTransactionX(transaction, &err)
|
||||
|
||||
updateTime := model.GetMillis()
|
||||
for _, value := range values {
|
||||
isPostgres := s.DriverName() == model.DatabaseDriverPostgres
|
||||
valueCase := sq.Case("id")
|
||||
deleteAtCase := sq.Case("id")
|
||||
ids := make([]string, len(values))
|
||||
|
||||
for i, value := range values {
|
||||
value.UpdateAt = updateTime
|
||||
if vErr := value.IsValid(); vErr != nil {
|
||||
return nil, errors.Wrap(vErr, "property_value_update_isvalid")
|
||||
}
|
||||
|
||||
ids[i] = value.ID
|
||||
valueJSON := value.Value
|
||||
if s.IsBinaryParamEnabled() {
|
||||
valueJSON = AppendBinaryFlag(valueJSON)
|
||||
}
|
||||
|
||||
if isPostgres {
|
||||
valueCase = valueCase.When(sq.Expr("?", value.ID), sq.Expr("?::jsonb", valueJSON))
|
||||
deleteAtCase = deleteAtCase.When(sq.Expr("?", value.ID), sq.Expr("?::bigint", value.DeleteAt))
|
||||
} else {
|
||||
valueCase = valueCase.When(sq.Expr("?", value.ID), sq.Expr("?", valueJSON))
|
||||
deleteAtCase = deleteAtCase.When(sq.Expr("?", value.ID), sq.Expr("?", value.DeleteAt))
|
||||
}
|
||||
}
|
||||
|
||||
builder := s.getQueryBuilder().
|
||||
Update("PropertyValues").
|
||||
Set("Value", valueCase).
|
||||
Set("DeleteAt", deleteAtCase).
|
||||
Set("UpdateAt", updateTime).
|
||||
Where(sq.Eq{"id": ids})
|
||||
|
||||
result, err := transaction.ExecBuilder(builder)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_update_exec")
|
||||
}
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_update_rowsaffected")
|
||||
}
|
||||
if count != int64(len(values)) {
|
||||
return nil, errors.Errorf("failed to update, some property values were not found, got %d of %d", count, len(values))
|
||||
}
|
||||
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_update_commit_transaction")
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *SqlPropertyValueStore) Upsert(values []*model.PropertyValue) (_ []*model.PropertyValue, err error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
transaction, err := s.GetMaster().Beginx()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_upsert_begin_transaction")
|
||||
}
|
||||
defer finalizeTransactionX(transaction, &err)
|
||||
|
||||
updatedValues := make([]*model.PropertyValue, len(values))
|
||||
updateTime := model.GetMillis()
|
||||
for i, value := range values {
|
||||
value.PreSave()
|
||||
value.UpdateAt = updateTime
|
||||
|
||||
if err := value.IsValid(); err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_update_isvalid")
|
||||
return nil, errors.Wrap(err, "property_value_upsert_isvalid")
|
||||
}
|
||||
|
||||
valueJSON := value.Value
|
||||
@ -148,36 +224,69 @@ func (s *SqlPropertyValueStore) Update(values []*model.PropertyValue) (_ []*mode
|
||||
valueJSON = AppendBinaryFlag(valueJSON)
|
||||
}
|
||||
|
||||
queryString, args, err := s.getQueryBuilder().
|
||||
Update("PropertyValues").
|
||||
Set("Value", valueJSON).
|
||||
Set("UpdateAt", value.UpdateAt).
|
||||
Set("DeleteAt", value.DeleteAt).
|
||||
Where(sq.Eq{"id": value.ID}).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_update_tosql")
|
||||
builder := s.getQueryBuilder().
|
||||
Insert("PropertyValues").
|
||||
Columns("ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt").
|
||||
Values(value.ID, value.TargetID, value.TargetType, value.GroupID, value.FieldID, valueJSON, value.CreateAt, value.UpdateAt, value.DeleteAt)
|
||||
|
||||
if s.DriverName() == model.DatabaseDriverMysql {
|
||||
builder = builder.SuffixExpr(sq.Expr(
|
||||
"ON DUPLICATE KEY UPDATE Value = ?, UpdateAt = ?, DeleteAt = ?",
|
||||
valueJSON,
|
||||
value.UpdateAt,
|
||||
0,
|
||||
))
|
||||
|
||||
if _, err := transaction.ExecBuilder(builder); err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_upsert_exec")
|
||||
}
|
||||
|
||||
result, err := transaction.Exec(queryString, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to update property value with id: %s", value.ID)
|
||||
// MySQL doesn't support RETURNING, so we need to fetch
|
||||
// the new field to get its ID in case we hit a DUPLICATED
|
||||
// KEY and the value.ID we have is not the right one
|
||||
gBuilder := s.tableSelectQuery.Where(sq.Eq{
|
||||
"GroupID": value.GroupID,
|
||||
"TargetID": value.TargetID,
|
||||
"FieldID": value.FieldID,
|
||||
"DeleteAt": 0,
|
||||
})
|
||||
|
||||
var values []*model.PropertyValue
|
||||
if gErr := transaction.SelectBuilder(&values, gBuilder); gErr != nil {
|
||||
return nil, errors.Wrap(gErr, "property_value_upsert_select")
|
||||
}
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_update_rowsaffected")
|
||||
if len(values) != 1 {
|
||||
return nil, errors.New("property_value_upsert_select_length")
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, store.NewErrNotFound("PropertyValue", value.ID)
|
||||
|
||||
updatedValues[i] = values[0]
|
||||
} else {
|
||||
builder = builder.SuffixExpr(sq.Expr(
|
||||
"ON CONFLICT (GroupID, TargetID, FieldID) WHERE DeleteAt = 0 DO UPDATE SET Value = ?, UpdateAt = ?, DeleteAt = ? RETURNING *",
|
||||
valueJSON,
|
||||
value.UpdateAt,
|
||||
0,
|
||||
))
|
||||
|
||||
var values []*model.PropertyValue
|
||||
if err := transaction.SelectBuilder(&values, builder); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to upsert property value with id: %s", value.ID)
|
||||
}
|
||||
|
||||
if len(values) != 1 {
|
||||
return nil, errors.New("property_value_upsert_select_length")
|
||||
}
|
||||
|
||||
updatedValues[i] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return nil, errors.Wrap(err, "property_value_update_commit")
|
||||
return nil, errors.Wrap(err, "property_value_upsert_commit")
|
||||
}
|
||||
|
||||
return values, nil
|
||||
return updatedValues, nil
|
||||
}
|
||||
|
||||
func (s *SqlPropertyValueStore) Delete(id string) error {
|
||||
|
@ -1059,7 +1059,7 @@ type PostPersistentNotificationStore interface {
|
||||
DeleteByTeam(teamIds []string) error
|
||||
}
|
||||
type ChannelBookmarkStore interface {
|
||||
ErrorIfBookmarkFileInfoAlreadyAttached(fileID string) error
|
||||
ErrorIfBookmarkFileInfoAlreadyAttached(fileID string, channelID string) error
|
||||
Get(Id string, includeDeleted bool) (b *model.ChannelBookmarkWithFileInfo, err error)
|
||||
Save(bookmark *model.ChannelBookmark, increaseSortOrder bool) (b *model.ChannelBookmarkWithFileInfo, err error)
|
||||
Update(bookmark *model.ChannelBookmark) error
|
||||
@ -1089,8 +1089,9 @@ type PropertyFieldStore interface {
|
||||
Create(field *model.PropertyField) (*model.PropertyField, error)
|
||||
Get(id string) (*model.PropertyField, error)
|
||||
GetMany(ids []string) ([]*model.PropertyField, error)
|
||||
CountForGroup(groupID string, includeDeleted bool) (int64, error)
|
||||
SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error)
|
||||
Update(field []*model.PropertyField) ([]*model.PropertyField, error)
|
||||
Update(fields []*model.PropertyField) ([]*model.PropertyField, error)
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
@ -1099,7 +1100,8 @@ type PropertyValueStore interface {
|
||||
Get(id string) (*model.PropertyValue, error)
|
||||
GetMany(ids []string) ([]*model.PropertyValue, error)
|
||||
SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error)
|
||||
Update(field []*model.PropertyValue) ([]*model.PropertyValue, error)
|
||||
Update(values []*model.PropertyValue) ([]*model.PropertyValue, error)
|
||||
Upsert(values []*model.PropertyValue) ([]*model.PropertyValue, error)
|
||||
Delete(id string) error
|
||||
DeleteForField(id string) error
|
||||
}
|
||||
|
@ -34,8 +34,12 @@ func TestChannelBookmarkStore(t *testing.T, rctx request.CTX, ss store.Store, s
|
||||
|
||||
func testSaveChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
channelID := model.NewId()
|
||||
otherChannelID := model.NewId()
|
||||
userID := model.NewId()
|
||||
|
||||
createAt := time.Now().Add(-1 * time.Minute)
|
||||
deleteAt := createAt.Add(1 * time.Second)
|
||||
|
||||
bookmark1 := &model.ChannelBookmark{
|
||||
ChannelId: channelID,
|
||||
OwnerId: userID,
|
||||
@ -47,6 +51,7 @@ func testSaveChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
|
||||
file := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: channelID,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
@ -80,6 +85,7 @@ func testSaveChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
|
||||
file2 := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: channelID,
|
||||
CreatorId: userID,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
@ -102,6 +108,60 @@ func testSaveChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
Emoji: ":smile:",
|
||||
}
|
||||
|
||||
deletedFile := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: channelID,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
PreviewPath: "prevPath",
|
||||
Name: "test file",
|
||||
Extension: "png",
|
||||
MimeType: "images/png",
|
||||
Size: 873182,
|
||||
Width: 3076,
|
||||
Height: 2200,
|
||||
HasPreviewImage: true,
|
||||
CreateAt: createAt.UnixMilli(),
|
||||
UpdateAt: createAt.UnixMilli(),
|
||||
DeleteAt: deleteAt.UnixMilli(),
|
||||
}
|
||||
|
||||
bookmarkFileDeleted := &model.ChannelBookmark{
|
||||
ChannelId: channelID,
|
||||
OwnerId: userID,
|
||||
DisplayName: "file deleted",
|
||||
FileId: deletedFile.Id,
|
||||
Type: model.ChannelBookmarkFile,
|
||||
Emoji: ":smile:",
|
||||
}
|
||||
|
||||
// another channel
|
||||
anotherChannelFile := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: otherChannelID,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
PreviewPath: "prevPath",
|
||||
Name: "test file",
|
||||
Extension: "png",
|
||||
MimeType: "images/png",
|
||||
Size: 873182,
|
||||
Width: 3076,
|
||||
Height: 2200,
|
||||
HasPreviewImage: true,
|
||||
}
|
||||
|
||||
bookmarkFileAnotherChannel := &model.ChannelBookmark{
|
||||
ChannelId: channelID,
|
||||
OwnerId: userID,
|
||||
DisplayName: "file another channel",
|
||||
FileId: anotherChannelFile.Id,
|
||||
Type: model.ChannelBookmarkFile,
|
||||
Emoji: ":smile:",
|
||||
}
|
||||
|
||||
_, err := ss.FileInfo().Save(rctx, file)
|
||||
require.NoError(t, err)
|
||||
defer ss.FileInfo().PermanentDelete(rctx, file.Id)
|
||||
@ -113,6 +173,14 @@ func testSaveChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
err = ss.FileInfo().AttachToPost(rctx, file2.Id, model.NewId(), channelID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ss.FileInfo().Save(rctx, deletedFile)
|
||||
require.NoError(t, err)
|
||||
defer ss.FileInfo().PermanentDelete(rctx, deletedFile.Id)
|
||||
|
||||
_, err = ss.FileInfo().Save(rctx, anotherChannelFile)
|
||||
require.NoError(t, err)
|
||||
defer ss.FileInfo().PermanentDelete(rctx, anotherChannelFile.Id)
|
||||
|
||||
t.Run("save bookmarks", func(t *testing.T) {
|
||||
bookmarkResp, err := ss.ChannelBookmark().Save(bookmark1.Clone(), true)
|
||||
assert.NoError(t, err)
|
||||
@ -137,6 +205,12 @@ func testSaveChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
|
||||
_, err = ss.ChannelBookmark().Save(bookmark4.Clone(), true)
|
||||
assert.Error(t, err) // Error as the file is attached to a post
|
||||
|
||||
_, err = ss.ChannelBookmark().Save(bookmarkFileDeleted.Clone(), true)
|
||||
assert.Error(t, err) // Error as the file is deleted
|
||||
|
||||
_, err = ss.ChannelBookmark().Save(bookmarkFileAnotherChannel.Clone(), true)
|
||||
assert.Error(t, err) // Error as the file is from another channel
|
||||
})
|
||||
}
|
||||
|
||||
@ -204,6 +278,7 @@ func testUpdateSortOrderChannelBookmark(t *testing.T, rctx request.CTX, ss store
|
||||
|
||||
file := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: channelID,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
@ -391,6 +466,7 @@ func testDeleteChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
|
||||
file := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: channelID,
|
||||
CreatorId: model.BookmarkFileOwner,
|
||||
Path: "somepath",
|
||||
ThumbnailPath: "thumbpath",
|
||||
|
@ -32,17 +32,17 @@ func (_m *ChannelBookmarkStore) Delete(bookmarkID string, deleteFile bool) error
|
||||
return r0
|
||||
}
|
||||
|
||||
// ErrorIfBookmarkFileInfoAlreadyAttached provides a mock function with given fields: fileID
|
||||
func (_m *ChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileID string) error {
|
||||
ret := _m.Called(fileID)
|
||||
// ErrorIfBookmarkFileInfoAlreadyAttached provides a mock function with given fields: fileID, channelID
|
||||
func (_m *ChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileID string, channelID string) error {
|
||||
ret := _m.Called(fileID, channelID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ErrorIfBookmarkFileInfoAlreadyAttached")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = rf(fileID)
|
||||
if rf, ok := ret.Get(0).(func(string, string) error); ok {
|
||||
r0 = rf(fileID, channelID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
@ -14,6 +14,34 @@ type PropertyFieldStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// CountForGroup provides a mock function with given fields: groupID, includeDeleted
|
||||
func (_m *PropertyFieldStore) CountForGroup(groupID string, includeDeleted bool) (int64, error) {
|
||||
ret := _m.Called(groupID, includeDeleted)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CountForGroup")
|
||||
}
|
||||
|
||||
var r0 int64
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string, bool) (int64, error)); ok {
|
||||
return rf(groupID, includeDeleted)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string, bool) int64); ok {
|
||||
r0 = rf(groupID, includeDeleted)
|
||||
} else {
|
||||
r0 = ret.Get(0).(int64)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string, bool) error); ok {
|
||||
r1 = rf(groupID, includeDeleted)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Create provides a mock function with given fields: field
|
||||
func (_m *PropertyFieldStore) Create(field *model.PropertyField) (*model.PropertyField, error) {
|
||||
ret := _m.Called(field)
|
||||
@ -152,9 +180,9 @@ func (_m *PropertyFieldStore) SearchPropertyFields(opts model.PropertyFieldSearc
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: field
|
||||
func (_m *PropertyFieldStore) Update(field []*model.PropertyField) ([]*model.PropertyField, error) {
|
||||
ret := _m.Called(field)
|
||||
// Update provides a mock function with given fields: fields
|
||||
func (_m *PropertyFieldStore) Update(fields []*model.PropertyField) ([]*model.PropertyField, error) {
|
||||
ret := _m.Called(fields)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Update")
|
||||
@ -163,10 +191,10 @@ func (_m *PropertyFieldStore) Update(field []*model.PropertyField) ([]*model.Pro
|
||||
var r0 []*model.PropertyField
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func([]*model.PropertyField) ([]*model.PropertyField, error)); ok {
|
||||
return rf(field)
|
||||
return rf(fields)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func([]*model.PropertyField) []*model.PropertyField); ok {
|
||||
r0 = rf(field)
|
||||
r0 = rf(fields)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.PropertyField)
|
||||
@ -174,7 +202,7 @@ func (_m *PropertyFieldStore) Update(field []*model.PropertyField) ([]*model.Pro
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func([]*model.PropertyField) error); ok {
|
||||
r1 = rf(field)
|
||||
r1 = rf(fields)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
@ -170,9 +170,9 @@ func (_m *PropertyValueStore) SearchPropertyValues(opts model.PropertyValueSearc
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: field
|
||||
func (_m *PropertyValueStore) Update(field []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
ret := _m.Called(field)
|
||||
// Update provides a mock function with given fields: values
|
||||
func (_m *PropertyValueStore) Update(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
ret := _m.Called(values)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Update")
|
||||
@ -181,10 +181,10 @@ func (_m *PropertyValueStore) Update(field []*model.PropertyValue) ([]*model.Pro
|
||||
var r0 []*model.PropertyValue
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func([]*model.PropertyValue) ([]*model.PropertyValue, error)); ok {
|
||||
return rf(field)
|
||||
return rf(values)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func([]*model.PropertyValue) []*model.PropertyValue); ok {
|
||||
r0 = rf(field)
|
||||
r0 = rf(values)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.PropertyValue)
|
||||
@ -192,7 +192,37 @@ func (_m *PropertyValueStore) Update(field []*model.PropertyValue) ([]*model.Pro
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func([]*model.PropertyValue) error); ok {
|
||||
r1 = rf(field)
|
||||
r1 = rf(values)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Upsert provides a mock function with given fields: values
|
||||
func (_m *PropertyValueStore) Upsert(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
ret := _m.Called(values)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Upsert")
|
||||
}
|
||||
|
||||
var r0 []*model.PropertyValue
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func([]*model.PropertyValue) ([]*model.PropertyValue, error)); ok {
|
||||
return rf(values)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func([]*model.PropertyValue) []*model.PropertyValue); ok {
|
||||
r0 = rf(values)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.PropertyValue)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func([]*model.PropertyValue) error); ok {
|
||||
r1 = rf(values)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ package storetest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -21,6 +22,7 @@ func TestPropertyFieldStore(t *testing.T, rctx request.CTX, ss store.Store, s Sq
|
||||
t.Run("UpdatePropertyField", func(t *testing.T) { testUpdatePropertyField(t, rctx, ss) })
|
||||
t.Run("DeletePropertyField", func(t *testing.T) { testDeletePropertyField(t, rctx, ss) })
|
||||
t.Run("SearchPropertyFields", func(t *testing.T) { testSearchPropertyFields(t, rctx, ss) })
|
||||
t.Run("CountForGroup", func(t *testing.T) { testCountForGroup(t, rctx, ss) })
|
||||
}
|
||||
|
||||
func testCreatePropertyField(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
@ -146,8 +148,7 @@ func testUpdatePropertyField(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
}
|
||||
updatedField, err := ss.PropertyField().Update([]*model.PropertyField{field})
|
||||
require.Zero(t, updatedField)
|
||||
var enf *store.ErrNotFound
|
||||
require.ErrorAs(t, err, &enf)
|
||||
require.ErrorContains(t, err, "failed to update, some property fields were not found, got 0 of 1")
|
||||
})
|
||||
|
||||
t.Run("should fail if the property field is not valid", func(t *testing.T) {
|
||||
@ -280,6 +281,46 @@ func testUpdatePropertyField(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
require.Equal(t, groupID, updated2.GroupID)
|
||||
require.Equal(t, originalUpdateAt2, updated2.UpdateAt)
|
||||
})
|
||||
|
||||
t.Run("should not update any fields if one update points to a nonexisting one", func(t *testing.T) {
|
||||
// Create a valid field
|
||||
field1 := &model.PropertyField{
|
||||
GroupID: model.NewId(),
|
||||
Name: "First field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
}
|
||||
|
||||
_, err := ss.PropertyField().Create(field1)
|
||||
require.NoError(t, err)
|
||||
|
||||
originalUpdateAt := field1.UpdateAt
|
||||
|
||||
// Try to update both the valid field and a nonexistent one
|
||||
field2 := &model.PropertyField{
|
||||
ID: model.NewId(),
|
||||
GroupID: model.NewId(),
|
||||
Name: "Second field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
CreateAt: 1,
|
||||
Attrs: map[string]any{
|
||||
"key": "value",
|
||||
},
|
||||
}
|
||||
|
||||
field1.Name = "Updated First"
|
||||
|
||||
_, err = ss.PropertyField().Update([]*model.PropertyField{field1, field2})
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "failed to update, some property fields were not found")
|
||||
|
||||
// Check that the valid field was not updated
|
||||
updated1, err := ss.PropertyField().Get(field1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "First field", updated1.Name)
|
||||
require.Equal(t, originalUpdateAt, updated1.UpdateAt)
|
||||
})
|
||||
}
|
||||
|
||||
func testDeletePropertyField(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
@ -317,6 +358,97 @@ func testDeletePropertyField(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
})
|
||||
}
|
||||
|
||||
func testCountForGroup(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
t.Run("should return 0 for group with no properties", func(t *testing.T) {
|
||||
count, err := ss.PropertyField().CountForGroup(model.NewId(), false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), count)
|
||||
})
|
||||
|
||||
t.Run("should return correct count for group with properties", func(t *testing.T) {
|
||||
groupID := model.NewId()
|
||||
|
||||
// Create 5 property fields
|
||||
for i := 0; i < 5; i++ {
|
||||
field := &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: fmt.Sprintf("Field %d", i),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
}
|
||||
_, err := ss.PropertyField().Create(field)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
count, err := ss.PropertyField().CountForGroup(groupID, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(5), count)
|
||||
})
|
||||
|
||||
t.Run("should not count deleted properties when includeDeleted is false", func(t *testing.T) {
|
||||
groupID := model.NewId()
|
||||
|
||||
// Create 5 property fields
|
||||
for i := 0; i < 5; i++ {
|
||||
field := &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: fmt.Sprintf("Field %d", i),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
}
|
||||
_, err := ss.PropertyField().Create(field)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create one more and delete it
|
||||
deletedField := &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "To be deleted",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
}
|
||||
_, err := ss.PropertyField().Create(deletedField)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ss.PropertyField().Delete(deletedField.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count should be 5 since the deleted field shouldn't be counted
|
||||
count, err := ss.PropertyField().CountForGroup(groupID, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(5), count)
|
||||
})
|
||||
|
||||
t.Run("should count deleted properties when includeDeleted is true", func(t *testing.T) {
|
||||
groupID := model.NewId()
|
||||
|
||||
// Create 5 property fields
|
||||
for i := 0; i < 5; i++ {
|
||||
field := &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: fmt.Sprintf("Field %d", i),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
}
|
||||
_, err := ss.PropertyField().Create(field)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create one more and delete it
|
||||
deletedField := &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "To be deleted",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
}
|
||||
_, err := ss.PropertyField().Create(deletedField)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ss.PropertyField().Delete(deletedField.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count should be 6 since we're including deleted fields
|
||||
count, err := ss.PropertyField().CountForGroup(groupID, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(6), count)
|
||||
})
|
||||
}
|
||||
|
||||
func testSearchPropertyFields(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
groupID := model.NewId()
|
||||
targetID := model.NewId()
|
||||
@ -367,18 +499,9 @@ func testSearchPropertyFields(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
expectedError bool
|
||||
expectedIDs []string
|
||||
}{
|
||||
{
|
||||
name: "negative page",
|
||||
opts: model.PropertyFieldSearchOpts{
|
||||
Page: -1,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "negative per_page",
|
||||
opts: model.PropertyFieldSearchOpts{
|
||||
Page: 0,
|
||||
PerPage: -1,
|
||||
},
|
||||
expectedError: true,
|
||||
@ -387,7 +510,6 @@ func testSearchPropertyFields(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
name: "filter by group_id",
|
||||
opts: model.PropertyFieldSearchOpts{
|
||||
GroupID: groupID,
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedIDs: []string{field1.ID, field2.ID},
|
||||
@ -396,7 +518,6 @@ func testSearchPropertyFields(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
name: "filter by group_id including deleted",
|
||||
opts: model.PropertyFieldSearchOpts{
|
||||
GroupID: groupID,
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
IncludeDeleted: true,
|
||||
},
|
||||
@ -406,7 +527,6 @@ func testSearchPropertyFields(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
name: "filter by target_type",
|
||||
opts: model.PropertyFieldSearchOpts{
|
||||
TargetType: "test_type",
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedIDs: []string{field1.ID, field3.ID},
|
||||
@ -415,7 +535,6 @@ func testSearchPropertyFields(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
name: "filter by target_id",
|
||||
opts: model.PropertyFieldSearchOpts{
|
||||
TargetID: targetID,
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedIDs: []string{field1.ID, field2.ID},
|
||||
@ -424,7 +543,6 @@ func testSearchPropertyFields(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
name: "pagination page 0",
|
||||
opts: model.PropertyFieldSearchOpts{
|
||||
GroupID: groupID,
|
||||
Page: 0,
|
||||
PerPage: 2,
|
||||
IncludeDeleted: true,
|
||||
},
|
||||
@ -434,7 +552,10 @@ func testSearchPropertyFields(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
name: "pagination page 1",
|
||||
opts: model.PropertyFieldSearchOpts{
|
||||
GroupID: groupID,
|
||||
Page: 1,
|
||||
Cursor: model.PropertyFieldSearchCursor{
|
||||
CreateAt: field2.CreateAt,
|
||||
PropertyFieldID: field2.ID,
|
||||
},
|
||||
PerPage: 2,
|
||||
IncludeDeleted: true,
|
||||
},
|
||||
@ -451,7 +572,7 @@ func testSearchPropertyFields(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
var ids = make([]string, len(results))
|
||||
ids := make([]string, len(results))
|
||||
for i, field := range results {
|
||||
ids[i] = field.ID
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ func TestPropertyValueStore(t *testing.T, rctx request.CTX, ss store.Store, s Sq
|
||||
t.Run("GetPropertyValue", func(t *testing.T) { testGetPropertyValue(t, rctx, ss) })
|
||||
t.Run("GetManyPropertyValues", func(t *testing.T) { testGetManyPropertyValues(t, rctx, ss) })
|
||||
t.Run("UpdatePropertyValue", func(t *testing.T) { testUpdatePropertyValue(t, rctx, ss) })
|
||||
t.Run("UpsertPropertyValue", func(t *testing.T) { testUpsertPropertyValue(t, rctx, ss) })
|
||||
t.Run("DeletePropertyValue", func(t *testing.T) { testDeletePropertyValue(t, rctx, ss) })
|
||||
t.Run("SearchPropertyValues", func(t *testing.T) { testSearchPropertyValues(t, rctx, ss) })
|
||||
t.Run("DeleteForField", func(t *testing.T) { testDeleteForField(t, rctx, ss) })
|
||||
@ -149,8 +150,7 @@ func testUpdatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
}
|
||||
updatedValue, err := ss.PropertyValue().Update([]*model.PropertyValue{value})
|
||||
require.Zero(t, updatedValue)
|
||||
var enf *store.ErrNotFound
|
||||
require.ErrorAs(t, err, &enf)
|
||||
require.ErrorContains(t, err, "failed to update, some property values were not found, got 0 of 1")
|
||||
})
|
||||
|
||||
t.Run("should fail if the property value is not valid", func(t *testing.T) {
|
||||
@ -220,7 +220,7 @@ func testUpdatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
require.Greater(t, updated2.UpdateAt, updated2.CreateAt)
|
||||
})
|
||||
|
||||
t.Run("should not update any fields if one update is invalid", func(t *testing.T) {
|
||||
t.Run("should not update any values if one update is invalid", func(t *testing.T) {
|
||||
// Create two valid values
|
||||
groupID := model.NewId()
|
||||
value1 := &model.PropertyValue{
|
||||
@ -266,6 +266,208 @@ func testUpdatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
require.Equal(t, groupID, updated2.GroupID)
|
||||
require.Equal(t, originalUpdateAt2, updated2.UpdateAt)
|
||||
})
|
||||
|
||||
t.Run("should not update any values if one update points to a nonexisting one", func(t *testing.T) {
|
||||
// Create a valid value
|
||||
value1 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"Value 1"`),
|
||||
}
|
||||
|
||||
_, err := ss.PropertyValue().Create(value1)
|
||||
require.NoError(t, err)
|
||||
|
||||
originalUpdateAt := value1.UpdateAt
|
||||
|
||||
// Try to update both the valid value and a nonexistent one
|
||||
value2 := &model.PropertyValue{
|
||||
ID: model.NewId(),
|
||||
TargetID: model.NewId(),
|
||||
CreateAt: 1,
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"Value 2"`),
|
||||
}
|
||||
|
||||
value1.Value = json.RawMessage(`"Updated Value 1"`)
|
||||
|
||||
_, err = ss.PropertyValue().Update([]*model.PropertyValue{value1, value2})
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "failed to update, some property values were not found")
|
||||
|
||||
// Check that the valid value was not updated
|
||||
updated1, err := ss.PropertyValue().Get(value1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, json.RawMessage(`"Value 1"`), updated1.Value)
|
||||
require.Equal(t, originalUpdateAt, updated1.UpdateAt)
|
||||
})
|
||||
}
|
||||
|
||||
func testUpsertPropertyValue(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
t.Run("should fail if the property value is not valid", func(t *testing.T) {
|
||||
value := &model.PropertyValue{
|
||||
TargetID: "",
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"test value"`),
|
||||
}
|
||||
updatedValue, err := ss.PropertyValue().Upsert([]*model.PropertyValue{value})
|
||||
require.Zero(t, updatedValue)
|
||||
require.ErrorContains(t, err, "model.property_value.is_valid.app_error")
|
||||
|
||||
value.TargetID = model.NewId()
|
||||
value.GroupID = ""
|
||||
updatedValue, err = ss.PropertyValue().Upsert([]*model.PropertyValue{value})
|
||||
require.Zero(t, updatedValue)
|
||||
require.ErrorContains(t, err, "model.property_value.is_valid.app_error")
|
||||
})
|
||||
|
||||
t.Run("should be able to insert new property values", func(t *testing.T) {
|
||||
value1 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"value 1"`),
|
||||
}
|
||||
|
||||
value2 := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"value 2"`),
|
||||
}
|
||||
|
||||
values, err := ss.PropertyValue().Upsert([]*model.PropertyValue{value1, value2})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, values, 2)
|
||||
require.NotEmpty(t, values[0].ID)
|
||||
require.NotEmpty(t, values[1].ID)
|
||||
require.NotZero(t, values[0].CreateAt)
|
||||
require.NotZero(t, values[1].CreateAt)
|
||||
|
||||
valuesFromStore, err := ss.PropertyValue().GetMany([]string{values[0].ID, values[1].ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, valuesFromStore, 2)
|
||||
})
|
||||
|
||||
t.Run("should be able to update existing property values", func(t *testing.T) {
|
||||
// Create initial value
|
||||
value := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"initial value"`),
|
||||
}
|
||||
_, err := ss.PropertyValue().Create(value)
|
||||
require.NoError(t, err)
|
||||
valueID := value.ID
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Update via upsert
|
||||
value.ID = ""
|
||||
value.Value = json.RawMessage(`"updated value"`)
|
||||
values, err := ss.PropertyValue().Upsert([]*model.PropertyValue{value})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, values, 1)
|
||||
require.Equal(t, valueID, values[0].ID)
|
||||
require.Equal(t, json.RawMessage(`"updated value"`), values[0].Value)
|
||||
require.Greater(t, values[0].UpdateAt, values[0].CreateAt)
|
||||
|
||||
// Verify in database
|
||||
updated, err := ss.PropertyValue().Get(valueID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, json.RawMessage(`"updated value"`), updated.Value)
|
||||
require.Greater(t, updated.UpdateAt, updated.CreateAt)
|
||||
})
|
||||
|
||||
t.Run("should handle mixed insert and update operations", func(t *testing.T) {
|
||||
// Create first value
|
||||
existingValue := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"existing value"`),
|
||||
}
|
||||
_, err := ss.PropertyValue().Create(existingValue)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Prepare new value
|
||||
newValue := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"new value"`),
|
||||
}
|
||||
|
||||
// Update existing and insert new via upsert
|
||||
existingValue.Value = json.RawMessage(`"updated existing"`)
|
||||
values, err := ss.PropertyValue().Upsert([]*model.PropertyValue{existingValue, newValue})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, values, 2)
|
||||
|
||||
// Verify both values
|
||||
newValueUpserted, err := ss.PropertyValue().Get(newValue.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, json.RawMessage(`"new value"`), newValueUpserted.Value)
|
||||
existingValueUpserted, err := ss.PropertyValue().Get(existingValue.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, json.RawMessage(`"updated existing"`), existingValueUpserted.Value)
|
||||
})
|
||||
|
||||
t.Run("should not perform any operation if one of the fields is invalid", func(t *testing.T) {
|
||||
// Create initial valid value
|
||||
existingValue := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: model.NewId(),
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"existing value"`),
|
||||
}
|
||||
_, err := ss.PropertyValue().Create(existingValue)
|
||||
require.NoError(t, err)
|
||||
|
||||
originalValue := *existingValue
|
||||
|
||||
// Prepare an invalid value
|
||||
invalidValue := &model.PropertyValue{
|
||||
TargetID: model.NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: "", // Invalid: empty group ID
|
||||
FieldID: model.NewId(),
|
||||
Value: json.RawMessage(`"new value"`),
|
||||
}
|
||||
|
||||
// Try to update existing and insert invalid via upsert
|
||||
existingValue.Value = json.RawMessage(`"should not update"`)
|
||||
_, err = ss.PropertyValue().Upsert([]*model.PropertyValue{existingValue, invalidValue})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "model.property_value.is_valid.app_error")
|
||||
|
||||
// Verify the existing value was not changed
|
||||
retrieved, err := ss.PropertyValue().Get(existingValue.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, originalValue.Value, retrieved.Value)
|
||||
require.Equal(t, originalValue.UpdateAt, retrieved.UpdateAt)
|
||||
|
||||
// Verify the invalid value was not inserted
|
||||
results, err := ss.PropertyValue().SearchPropertyValues(model.PropertyValueSearchOpts{
|
||||
TargetID: invalidValue.TargetID,
|
||||
PerPage: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results)
|
||||
})
|
||||
}
|
||||
|
||||
func testDeletePropertyValue(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
@ -364,18 +566,9 @@ func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
expectedError bool
|
||||
expectedIDs []string
|
||||
}{
|
||||
{
|
||||
name: "negative page",
|
||||
opts: model.PropertyValueSearchOpts{
|
||||
Page: -1,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "negative per_page",
|
||||
opts: model.PropertyValueSearchOpts{
|
||||
Page: 0,
|
||||
PerPage: -1,
|
||||
},
|
||||
expectedError: true,
|
||||
@ -384,7 +577,6 @@ func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
name: "filter by group_id",
|
||||
opts: model.PropertyValueSearchOpts{
|
||||
GroupID: groupID,
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedIDs: []string{value1.ID, value2.ID},
|
||||
@ -394,7 +586,6 @@ func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
opts: model.PropertyValueSearchOpts{
|
||||
GroupID: groupID,
|
||||
TargetType: "test_type",
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedIDs: []string{value1.ID},
|
||||
@ -405,7 +596,6 @@ func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
GroupID: groupID,
|
||||
TargetType: "test_type",
|
||||
IncludeDeleted: true,
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedIDs: []string{value1.ID, value4.ID},
|
||||
@ -414,7 +604,6 @@ func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
name: "filter by target_id",
|
||||
opts: model.PropertyValueSearchOpts{
|
||||
TargetID: targetID,
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedIDs: []string{value1.ID, value2.ID},
|
||||
@ -424,7 +613,6 @@ func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
opts: model.PropertyValueSearchOpts{
|
||||
GroupID: groupID,
|
||||
TargetID: targetID,
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedIDs: []string{value1.ID, value2.ID},
|
||||
@ -433,7 +621,6 @@ func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
name: "filter by field_id",
|
||||
opts: model.PropertyValueSearchOpts{
|
||||
FieldID: fieldID,
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedIDs: []string{value1.ID},
|
||||
@ -443,7 +630,6 @@ func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
opts: model.PropertyValueSearchOpts{
|
||||
FieldID: fieldID,
|
||||
IncludeDeleted: true,
|
||||
Page: 0,
|
||||
PerPage: 10,
|
||||
},
|
||||
expectedIDs: []string{value1.ID, value4.ID},
|
||||
@ -452,7 +638,6 @@ func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
name: "pagination page 0",
|
||||
opts: model.PropertyValueSearchOpts{
|
||||
GroupID: groupID,
|
||||
Page: 0,
|
||||
PerPage: 1,
|
||||
},
|
||||
expectedIDs: []string{value1.ID},
|
||||
@ -461,7 +646,10 @@ func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
name: "pagination page 1",
|
||||
opts: model.PropertyValueSearchOpts{
|
||||
GroupID: groupID,
|
||||
Page: 1,
|
||||
Cursor: model.PropertyValueSearchCursor{
|
||||
CreateAt: value1.CreateAt,
|
||||
PropertyValueID: value1.ID,
|
||||
},
|
||||
PerPage: 1,
|
||||
},
|
||||
expectedIDs: []string{value2.ID},
|
||||
@ -477,7 +665,7 @@ func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
var ids = make([]string, len(results))
|
||||
ids := make([]string, len(results))
|
||||
for i, value := range results {
|
||||
ids[i] = value.ID
|
||||
}
|
||||
|
@ -2614,10 +2614,10 @@ func (s *TimerLayerChannelBookmarkStore) Delete(bookmarkID string, deleteFile bo
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileID string) error {
|
||||
func (s *TimerLayerChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileID string, channelID string) error {
|
||||
start := time.Now()
|
||||
|
||||
err := s.ChannelBookmarkStore.ErrorIfBookmarkFileInfoAlreadyAttached(fileID)
|
||||
err := s.ChannelBookmarkStore.ErrorIfBookmarkFileInfoAlreadyAttached(fileID, channelID)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
@ -7105,6 +7105,22 @@ func (s *TimerLayerProductNoticesStore) View(userID string, notices []string) er
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPropertyFieldStore) CountForGroup(groupID string, includeDeleted bool) (int64, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.PropertyFieldStore.CountForGroup(groupID, includeDeleted)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("PropertyFieldStore.CountForGroup", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPropertyFieldStore) Create(field *model.PropertyField) (*model.PropertyField, error) {
|
||||
start := time.Now()
|
||||
|
||||
@ -7185,10 +7201,10 @@ func (s *TimerLayerPropertyFieldStore) SearchPropertyFields(opts model.PropertyF
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPropertyFieldStore) Update(field []*model.PropertyField) ([]*model.PropertyField, error) {
|
||||
func (s *TimerLayerPropertyFieldStore) Update(fields []*model.PropertyField) ([]*model.PropertyField, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.PropertyFieldStore.Update(field)
|
||||
result, err := s.PropertyFieldStore.Update(fields)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
@ -7329,10 +7345,10 @@ func (s *TimerLayerPropertyValueStore) SearchPropertyValues(opts model.PropertyV
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPropertyValueStore) Update(field []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
func (s *TimerLayerPropertyValueStore) Update(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.PropertyValueStore.Update(field)
|
||||
result, err := s.PropertyValueStore.Update(values)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
@ -7345,6 +7361,22 @@ func (s *TimerLayerPropertyValueStore) Update(field []*model.PropertyValue) ([]*
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPropertyValueStore) Upsert(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.PropertyValueStore.Upsert(values)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("PropertyValueStore.Upsert", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) {
|
||||
start := time.Now()
|
||||
|
||||
|
@ -108,16 +108,16 @@ type MetricsInterface interface {
|
||||
ObserveClientTimeToLastByte(platform, agent, userID string, elapsed float64)
|
||||
ObserveClientTimeToDomInteractive(platform, agent, userID string, elapsed float64)
|
||||
ObserveClientSplashScreenEnd(platform, agent, pageType, userID string, elapsed float64)
|
||||
ObserveClientFirstContentfulPaint(platform, agent string, elapsed float64)
|
||||
ObserveClientLargestContentfulPaint(platform, agent, region string, elapsed float64)
|
||||
ObserveClientInteractionToNextPaint(platform, agent, interaction string, elapsed float64)
|
||||
ObserveClientCumulativeLayoutShift(platform, agent string, elapsed float64)
|
||||
IncrementClientLongTasks(platform, agent string, inc float64)
|
||||
ObserveClientFirstContentfulPaint(platform, agent, userID string, elapsed float64)
|
||||
ObserveClientLargestContentfulPaint(platform, agent, region, userID string, elapsed float64)
|
||||
ObserveClientInteractionToNextPaint(platform, agent, interaction, userID string, elapsed float64)
|
||||
ObserveClientCumulativeLayoutShift(platform, agent, userID string, elapsed float64)
|
||||
IncrementClientLongTasks(platform, agent, userID string, inc float64)
|
||||
ObserveClientPageLoadDuration(platform, agent, userID string, elapsed float64)
|
||||
ObserveClientChannelSwitchDuration(platform, agent, fresh string, elapsed float64)
|
||||
ObserveClientTeamSwitchDuration(platform, agent, fresh string, elapsed float64)
|
||||
ObserveClientRHSLoadDuration(platform, agent string, elapsed float64)
|
||||
ObserveGlobalThreadsLoadDuration(platform, agent string, elapsed float64)
|
||||
ObserveClientChannelSwitchDuration(platform, agent, fresh, userID string, elapsed float64)
|
||||
ObserveClientTeamSwitchDuration(platform, agent, fresh, userID string, elapsed float64)
|
||||
ObserveClientRHSLoadDuration(platform, agent, userID string, elapsed float64)
|
||||
ObserveGlobalThreadsLoadDuration(platform, agent, userID string, elapsed float64)
|
||||
ObserveMobileClientLoadDuration(platform string, elapsed float64)
|
||||
ObserveMobileClientChannelSwitchDuration(platform string, elapsed float64)
|
||||
ObserveMobileClientTeamSwitchDuration(platform string, elapsed float64)
|
||||
|
@ -78,9 +78,9 @@ func (_m *MetricsInterface) IncrementChannelIndexCounter() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// IncrementClientLongTasks provides a mock function with given fields: platform, agent, inc
|
||||
func (_m *MetricsInterface) IncrementClientLongTasks(platform string, agent string, inc float64) {
|
||||
_m.Called(platform, agent, inc)
|
||||
// IncrementClientLongTasks provides a mock function with given fields: platform, agent, userID, inc
|
||||
func (_m *MetricsInterface) IncrementClientLongTasks(platform string, agent string, userID string, inc float64) {
|
||||
_m.Called(platform, agent, userID, inc)
|
||||
}
|
||||
|
||||
// IncrementClusterEventType provides a mock function with given fields: eventType
|
||||
@ -303,29 +303,29 @@ func (_m *MetricsInterface) ObserveAPIEndpointDuration(endpoint string, method s
|
||||
_m.Called(endpoint, method, statusCode, originClient, pageLoadContext, elapsed)
|
||||
}
|
||||
|
||||
// ObserveClientChannelSwitchDuration provides a mock function with given fields: platform, agent, fresh, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientChannelSwitchDuration(platform string, agent string, fresh string, elapsed float64) {
|
||||
_m.Called(platform, agent, fresh, elapsed)
|
||||
// ObserveClientChannelSwitchDuration provides a mock function with given fields: platform, agent, fresh, userID, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientChannelSwitchDuration(platform string, agent string, fresh string, userID string, elapsed float64) {
|
||||
_m.Called(platform, agent, fresh, userID, elapsed)
|
||||
}
|
||||
|
||||
// ObserveClientCumulativeLayoutShift provides a mock function with given fields: platform, agent, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientCumulativeLayoutShift(platform string, agent string, elapsed float64) {
|
||||
_m.Called(platform, agent, elapsed)
|
||||
// ObserveClientCumulativeLayoutShift provides a mock function with given fields: platform, agent, userID, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientCumulativeLayoutShift(platform string, agent string, userID string, elapsed float64) {
|
||||
_m.Called(platform, agent, userID, elapsed)
|
||||
}
|
||||
|
||||
// ObserveClientFirstContentfulPaint provides a mock function with given fields: platform, agent, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientFirstContentfulPaint(platform string, agent string, elapsed float64) {
|
||||
_m.Called(platform, agent, elapsed)
|
||||
// ObserveClientFirstContentfulPaint provides a mock function with given fields: platform, agent, userID, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientFirstContentfulPaint(platform string, agent string, userID string, elapsed float64) {
|
||||
_m.Called(platform, agent, userID, elapsed)
|
||||
}
|
||||
|
||||
// ObserveClientInteractionToNextPaint provides a mock function with given fields: platform, agent, interaction, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientInteractionToNextPaint(platform string, agent string, interaction string, elapsed float64) {
|
||||
_m.Called(platform, agent, interaction, elapsed)
|
||||
// ObserveClientInteractionToNextPaint provides a mock function with given fields: platform, agent, interaction, userID, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientInteractionToNextPaint(platform string, agent string, interaction string, userID string, elapsed float64) {
|
||||
_m.Called(platform, agent, interaction, userID, elapsed)
|
||||
}
|
||||
|
||||
// ObserveClientLargestContentfulPaint provides a mock function with given fields: platform, agent, region, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientLargestContentfulPaint(platform string, agent string, region string, elapsed float64) {
|
||||
_m.Called(platform, agent, region, elapsed)
|
||||
// ObserveClientLargestContentfulPaint provides a mock function with given fields: platform, agent, region, userID, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientLargestContentfulPaint(platform string, agent string, region string, userID string, elapsed float64) {
|
||||
_m.Called(platform, agent, region, userID, elapsed)
|
||||
}
|
||||
|
||||
// ObserveClientPageLoadDuration provides a mock function with given fields: platform, agent, userID, elapsed
|
||||
@ -333,9 +333,9 @@ func (_m *MetricsInterface) ObserveClientPageLoadDuration(platform string, agent
|
||||
_m.Called(platform, agent, userID, elapsed)
|
||||
}
|
||||
|
||||
// ObserveClientRHSLoadDuration provides a mock function with given fields: platform, agent, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientRHSLoadDuration(platform string, agent string, elapsed float64) {
|
||||
_m.Called(platform, agent, elapsed)
|
||||
// ObserveClientRHSLoadDuration provides a mock function with given fields: platform, agent, userID, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientRHSLoadDuration(platform string, agent string, userID string, elapsed float64) {
|
||||
_m.Called(platform, agent, userID, elapsed)
|
||||
}
|
||||
|
||||
// ObserveClientSplashScreenEnd provides a mock function with given fields: platform, agent, pageType, userID, elapsed
|
||||
@ -343,9 +343,9 @@ func (_m *MetricsInterface) ObserveClientSplashScreenEnd(platform string, agent
|
||||
_m.Called(platform, agent, pageType, userID, elapsed)
|
||||
}
|
||||
|
||||
// ObserveClientTeamSwitchDuration provides a mock function with given fields: platform, agent, fresh, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientTeamSwitchDuration(platform string, agent string, fresh string, elapsed float64) {
|
||||
_m.Called(platform, agent, fresh, elapsed)
|
||||
// ObserveClientTeamSwitchDuration provides a mock function with given fields: platform, agent, fresh, userID, elapsed
|
||||
func (_m *MetricsInterface) ObserveClientTeamSwitchDuration(platform string, agent string, fresh string, userID string, elapsed float64) {
|
||||
_m.Called(platform, agent, fresh, userID, elapsed)
|
||||
}
|
||||
|
||||
// ObserveClientTimeToDomInteractive provides a mock function with given fields: platform, agent, userID, elapsed
|
||||
@ -388,9 +388,9 @@ func (_m *MetricsInterface) ObserveFilesSearchDuration(elapsed float64) {
|
||||
_m.Called(elapsed)
|
||||
}
|
||||
|
||||
// ObserveGlobalThreadsLoadDuration provides a mock function with given fields: platform, agent, elapsed
|
||||
func (_m *MetricsInterface) ObserveGlobalThreadsLoadDuration(platform string, agent string, elapsed float64) {
|
||||
_m.Called(platform, agent, elapsed)
|
||||
// ObserveGlobalThreadsLoadDuration provides a mock function with given fields: platform, agent, userID, elapsed
|
||||
func (_m *MetricsInterface) ObserveGlobalThreadsLoadDuration(platform string, agent string, userID string, elapsed float64) {
|
||||
_m.Called(platform, agent, userID, elapsed)
|
||||
}
|
||||
|
||||
// ObserveMobileClientChannelSwitchDuration provides a mock function with given fields: platform, elapsed
|
||||
|
@ -55,6 +55,8 @@ type MetricsInterfaceImpl struct {
|
||||
|
||||
Registry *prometheus.Registry
|
||||
|
||||
ClientSideUserIds map[string]bool
|
||||
|
||||
DbMasterConnectionsGauge prometheus.GaugeFunc
|
||||
DbReadConnectionsGauge prometheus.GaugeFunc
|
||||
DbSearchConnectionsGauge prometheus.GaugeFunc
|
||||
@ -240,7 +242,7 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
// New creates a new MetricsInterface. The driver and datasoruce parameters are added during
|
||||
// New creates a new MetricsInterface. The driver and datasource parameters are added during
|
||||
// migrating configuration store to the new platform service. Once the store and license are migrated,
|
||||
// we will be able to remove server dependency and lean on platform service during initialization.
|
||||
func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterfaceImpl {
|
||||
@ -248,6 +250,12 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Platform: ps,
|
||||
}
|
||||
|
||||
// Initialize ClientSideUserIds map
|
||||
m.ClientSideUserIds = make(map[string]bool)
|
||||
for _, userId := range ps.Config().MetricsSettings.ClientSideUserIds {
|
||||
m.ClientSideUserIds[userId] = true
|
||||
}
|
||||
|
||||
m.Registry = prometheus.NewRegistry()
|
||||
options := collectors.ProcessCollectorOpts{
|
||||
Namespace: MetricsNamespace,
|
||||
@ -1196,7 +1204,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Help: "Duration from when a browser starts to request a page from a server until when it starts to receive data in response (seconds)",
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent"},
|
||||
[]string{"platform", "agent", "user_id"},
|
||||
m.Platform.Log(),
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientTimeToFirstByte)
|
||||
@ -1209,7 +1217,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Help: "Duration from when a browser starts to request a page from a server until when it receives the last byte of the resource or immediately before the transport connection is closed, whichever comes first. (seconds)",
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent"},
|
||||
[]string{"platform", "agent", "user_id"},
|
||||
m.Platform.Log(),
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientTimeToLastByte)
|
||||
@ -1223,7 +1231,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Buckets: []float64{.1, .25, .5, 1, 2.5, 5, 7.5, 10, 12.5, 15},
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent"},
|
||||
[]string{"platform", "agent", "user_id"},
|
||||
m.Platform.Log(),
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientTimeToDOMInteractive)
|
||||
@ -1237,7 +1245,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Buckets: []float64{.1, .25, .5, 1, 2.5, 5, 7.5, 10, 12.5, 15},
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent", "page_type"},
|
||||
[]string{"platform", "agent", "page_type", "user_id"},
|
||||
m.Platform.Log(),
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientSplashScreenEnd)
|
||||
@ -1253,7 +1261,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 15, 20},
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent"},
|
||||
[]string{"platform", "agent", "user_id"},
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientFirstContentfulPaint)
|
||||
|
||||
@ -1268,7 +1276,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 15, 20},
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent", "region"},
|
||||
[]string{"platform", "agent", "region", "user_id"},
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientLargestContentfulPaint)
|
||||
|
||||
@ -1280,7 +1288,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Help: "Measure of how long it takes for a user to see the effects of clicking with a mouse, tapping with a touchscreen, or pressing a key on the keyboard (seconds)",
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent", "interaction"},
|
||||
[]string{"platform", "agent", "interaction", "user_id"},
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientInteractionToNextPaint)
|
||||
|
||||
@ -1292,7 +1300,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Help: "Measure of how much a page's content shifts unexpectedly",
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent"},
|
||||
[]string{"platform", "agent", "user_id"},
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientCumulativeLayoutShift)
|
||||
|
||||
@ -1304,7 +1312,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Help: "Counter of the number of times that the browser's main UI thread is blocked for more than 50ms by a single task",
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent"},
|
||||
[]string{"platform", "agent", "user_id"},
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientLongTasks)
|
||||
|
||||
@ -1317,7 +1325,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 20, 40},
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent"},
|
||||
[]string{"platform", "agent", "user_id"},
|
||||
m.Platform.Log(),
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientPageLoadDuration)
|
||||
@ -1330,7 +1338,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Help: "Duration of the time taken from when a user clicks on a channel in the LHS to when posts in that channel become visible (seconds)",
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent", "fresh"},
|
||||
[]string{"platform", "agent", "fresh", "user_id"},
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientChannelSwitchDuration)
|
||||
|
||||
@ -1342,7 +1350,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Help: "Duration of the time taken from when a user clicks on a team in the LHS to when posts in that team become visible (seconds)",
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent", "fresh"},
|
||||
[]string{"platform", "agent", "fresh", "user_id"},
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientTeamSwitchDuration)
|
||||
|
||||
@ -1354,7 +1362,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Help: "Duration of the time taken from when a user clicks to open a thread in the RHS until when posts in that thread become visible (seconds)",
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent"},
|
||||
[]string{"platform", "agent", "user_id"},
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientRHSLoadDuration)
|
||||
|
||||
@ -1366,7 +1374,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
|
||||
Help: "Duration of the time taken from when a user clicks to open Threads in the LHS until when the global threads view becomes visible (milliseconds)",
|
||||
ConstLabels: additionalLabels,
|
||||
},
|
||||
[]string{"platform", "agent"},
|
||||
[]string{"platform", "agent", "user_id"},
|
||||
)
|
||||
m.Registry.MustRegister(m.ClientGlobalThreadsLoadDuration)
|
||||
|
||||
@ -2024,63 +2032,81 @@ func (mi *MetricsInterfaceImpl) DecrementHTTPWebSockets(originClient string) {
|
||||
mi.HTTPWebsocketsGauge.With(prometheus.Labels{"origin_client": originClient}).Dec()
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) getEffectiveUserID(userID string) string {
|
||||
if mi.ClientSideUserIds[userID] {
|
||||
return userID
|
||||
}
|
||||
return "<placeholder>"
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientTimeToFirstByte(platform, agent, userID string, elapsed float64) {
|
||||
mi.ClientTimeToFirstByte.With(prometheus.Labels{"platform": platform, "agent": agent}, userID).Observe(elapsed)
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientTimeToFirstByte.With(prometheus.Labels{"platform": platform, "agent": agent, "user_id": effectiveUserID}, userID).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientTimeToLastByte(platform, agent, userID string, elapsed float64) {
|
||||
mi.ClientTimeToLastByte.With(prometheus.Labels{"platform": platform, "agent": agent}, userID).Observe(elapsed)
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientTimeToLastByte.With(prometheus.Labels{"platform": platform, "agent": agent, "user_id": effectiveUserID}, userID).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientTimeToDomInteractive(platform, agent, userID string, elapsed float64) {
|
||||
mi.ClientTimeToDOMInteractive.With(prometheus.Labels{"platform": platform, "agent": agent}, userID).Observe(elapsed)
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientTimeToDOMInteractive.With(prometheus.Labels{"platform": platform, "agent": agent, "user_id": effectiveUserID}, userID).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientSplashScreenEnd(platform, agent, pageType, userID string, elapsed float64) {
|
||||
mi.ClientSplashScreenEnd.With(prometheus.Labels{"platform": platform, "agent": agent, "page_type": pageType}, userID).Observe(elapsed)
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientSplashScreenEnd.With(prometheus.Labels{"platform": platform, "agent": agent, "page_type": pageType, "user_id": effectiveUserID}, userID).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientFirstContentfulPaint(platform, agent string, elapsed float64) {
|
||||
mi.ClientFirstContentfulPaint.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed)
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientFirstContentfulPaint(platform, agent, userID string, elapsed float64) {
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientFirstContentfulPaint.With(prometheus.Labels{"platform": platform, "agent": agent, "user_id": effectiveUserID}).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientLargestContentfulPaint(platform, agent, region string, elapsed float64) {
|
||||
mi.ClientLargestContentfulPaint.With(prometheus.Labels{"platform": platform, "agent": agent, "region": region}).Observe(elapsed)
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientLargestContentfulPaint(platform, agent, region, userID string, elapsed float64) {
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientLargestContentfulPaint.With(prometheus.Labels{"platform": platform, "agent": agent, "region": region, "user_id": effectiveUserID}).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientInteractionToNextPaint(platform, agent, interaction string, elapsed float64) {
|
||||
mi.ClientInteractionToNextPaint.With(prometheus.Labels{"platform": platform, "agent": agent, "interaction": interaction}).Observe(elapsed)
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientInteractionToNextPaint(platform, agent, interaction, userID string, elapsed float64) {
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientInteractionToNextPaint.With(prometheus.Labels{"platform": platform, "agent": agent, "interaction": interaction, "user_id": effectiveUserID}).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientCumulativeLayoutShift(platform, agent string, elapsed float64) {
|
||||
mi.ClientCumulativeLayoutShift.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed)
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientCumulativeLayoutShift(platform, agent, userID string, elapsed float64) {
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientCumulativeLayoutShift.With(prometheus.Labels{"platform": platform, "agent": agent, "user_id": effectiveUserID}).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) IncrementClientLongTasks(platform, agent string, inc float64) {
|
||||
mi.ClientLongTasks.With(prometheus.Labels{"platform": platform, "agent": agent}).Add(inc)
|
||||
func (mi *MetricsInterfaceImpl) IncrementClientLongTasks(platform, agent, userID string, inc float64) {
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientLongTasks.With(prometheus.Labels{"platform": platform, "agent": agent, "user_id": effectiveUserID}).Add(inc)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientPageLoadDuration(platform, agent, userID string, elapsed float64) {
|
||||
mi.ClientPageLoadDuration.With(prometheus.Labels{
|
||||
"platform": platform,
|
||||
"agent": agent,
|
||||
}, userID).Observe(elapsed)
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientPageLoadDuration.With(prometheus.Labels{"platform": platform, "agent": agent, "user_id": effectiveUserID}, userID).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientChannelSwitchDuration(platform, agent, fresh string, elapsed float64) {
|
||||
mi.ClientChannelSwitchDuration.With(prometheus.Labels{"platform": platform, "agent": agent, "fresh": fresh}).Observe(elapsed)
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientChannelSwitchDuration(platform, agent, fresh, userID string, elapsed float64) {
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientChannelSwitchDuration.With(prometheus.Labels{"platform": platform, "agent": agent, "fresh": fresh, "user_id": effectiveUserID}).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientTeamSwitchDuration(platform, agent, fresh string, elapsed float64) {
|
||||
mi.ClientTeamSwitchDuration.With(prometheus.Labels{"platform": platform, "agent": agent, "fresh": fresh}).Observe(elapsed)
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientTeamSwitchDuration(platform, agent, fresh, userID string, elapsed float64) {
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientTeamSwitchDuration.With(prometheus.Labels{"platform": platform, "agent": agent, "fresh": fresh, "user_id": effectiveUserID}).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientRHSLoadDuration(platform, agent string, elapsed float64) {
|
||||
mi.ClientRHSLoadDuration.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed)
|
||||
func (mi *MetricsInterfaceImpl) ObserveClientRHSLoadDuration(platform, agent, userID string, elapsed float64) {
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientRHSLoadDuration.With(prometheus.Labels{"platform": platform, "agent": agent, "user_id": effectiveUserID}).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveGlobalThreadsLoadDuration(platform, agent string, elapsed float64) {
|
||||
mi.ClientGlobalThreadsLoadDuration.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed)
|
||||
func (mi *MetricsInterfaceImpl) ObserveGlobalThreadsLoadDuration(platform, agent, userID string, elapsed float64) {
|
||||
effectiveUserID := mi.getEffectiveUserID(userID)
|
||||
mi.ClientGlobalThreadsLoadDuration.With(prometheus.Labels{"platform": platform, "agent": agent, "user_id": effectiveUserID}).Observe(elapsed)
|
||||
}
|
||||
|
||||
func (mi *MetricsInterfaceImpl) ObserveDesktopCpuUsage(platform, version, process string, usage float64) {
|
||||
|
@ -7807,10 +7807,6 @@
|
||||
"id": "app.channel.add_member.deleted_user.app_error",
|
||||
"translation": "Nelze přidat uživatele jako člena kanálu."
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "Schéma indexu kanálů pro vyhledávání je zastaralé. Doporučujeme znovu vygenerovat index kanálů.\nKlikněte na tlačítko `Rebuild Channels Index` [na stránce Elasticsearch prostřednictvím systémové konzole]({{.ElasticsearchSection}}), abyste tento problém vyřešili.\nDalší informace naleznete v changelogu Mattermost."
|
||||
},
|
||||
{
|
||||
"id": "app.channel.bookmark.save.app_error",
|
||||
"translation": "Nepodařilo se uložit záložku."
|
||||
@ -10394,5 +10390,17 @@
|
||||
{
|
||||
"id": "model.property_value.is_valid.app_error",
|
||||
"translation": "Neplatná hodnota vlastnosti: {{.FieldName}} ({{.Reason}})."
|
||||
},
|
||||
{
|
||||
"id": "api.custom_profile_attributes.license_error",
|
||||
"translation": "Vaše licence nepodporuje vlastní atributy profilu."
|
||||
},
|
||||
{
|
||||
"id": "api.file.zip_file_reader.app_error",
|
||||
"translation": "Nelze získat čtečku souborů ZIP."
|
||||
},
|
||||
{
|
||||
"id": "api.command.execute_command.deleted.error",
|
||||
"translation": "Nelze spustit příkaz v odstraněném kanálu."
|
||||
}
|
||||
]
|
||||
|
@ -9737,10 +9737,6 @@
|
||||
"id": "api.config.update.elasticsearch.autocomplete_cannot_be_enabled_error",
|
||||
"translation": "Die automatische Vervollständigung von Kanälen kann nicht aktiviert werden, da das Schema des Kanal-Index veraltet ist. Es wird empfohlen, den Kanal-Index neu zu erstellen. Weitere Informationen findest du im Mattermost Changelog"
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "Das Indexschema deines Suchkanals ist nicht mehr aktuell. Wir empfehlen dir, deinen Kanalindex neu zu erstellen.\nKlicke auf die Schaltfläche \"Index der Kanäle neu erstellen\" auf der [Elasticsearch-Seite über die Systemkonsole] ({{.ElasticsearchSection}}), um dieses Problem zu beheben.\nWeitere Informationen findest du im Mattermost Changelog."
|
||||
},
|
||||
{
|
||||
"id": "bleveengine.purge_list.not_implemented",
|
||||
"translation": "Die Funktion Bereinigungsliste ist für Bleve nicht verfügbar."
|
||||
@ -10398,5 +10394,9 @@
|
||||
{
|
||||
"id": "api.file.zip_file_reader.app_error",
|
||||
"translation": "Es ist nicht möglich, einen Zip-Datei-Leser zu bekommen."
|
||||
},
|
||||
{
|
||||
"id": "api.command.execute_command.deleted.error",
|
||||
"translation": "Kommando kann nicht in einem gelöschten Kanal ausgeführt werden."
|
||||
}
|
||||
]
|
||||
|
@ -1137,11 +1137,11 @@
|
||||
},
|
||||
{
|
||||
"id": "model.config.is_valid.elastic_search.enable_searching.app_error",
|
||||
"translation": "Search EnableIndexing setting must be set to true when Elasticsearch EnableSearching is set to true"
|
||||
"translation": "{{.EnableIndexing}} setting must be set to true when {{.Searching}} is set to true"
|
||||
},
|
||||
{
|
||||
"id": "model.config.is_valid.elastic_search.enable_autocomplete.app_error",
|
||||
"translation": "Search EnableIndexing setting must be set to true when Elasticsearch EnableAutocomplete is set to true"
|
||||
"translation": "{{.EnableIndexing}} setting must be set to true when {{.Autocomplete}} is set to true"
|
||||
},
|
||||
{
|
||||
"id": "model.config.is_valid.elastic_search.connection_url.app_error",
|
||||
@ -3125,11 +3125,11 @@
|
||||
},
|
||||
{
|
||||
"id": "ent.elasticsearch.test_config.reenter_password",
|
||||
"translation": "The Elasticsearch Server URL or Username has changed. Please re-enter the Elasticsearch password to test connection."
|
||||
"translation": "The Search Server URL or Username has changed. Please re-enter the password to test connection."
|
||||
},
|
||||
{
|
||||
"id": "ent.elasticsearch.test_config.license.error",
|
||||
"translation": "Your licence does not support Elasticsearch."
|
||||
"translation": "Your Mattermost licence doesn't support indexed search."
|
||||
},
|
||||
{
|
||||
"id": "ent.elasticsearch.test_config.indexing_disabled.error",
|
||||
@ -3197,7 +3197,7 @@
|
||||
},
|
||||
{
|
||||
"id": "ent.elasticsearch.refresh_indexes.refresh_failed",
|
||||
"translation": "Failed to refresh Elasticsearch indexes"
|
||||
"translation": "Failed to refresh search indexes"
|
||||
},
|
||||
{
|
||||
"id": "ent.elasticsearch.post.get_posts_batch_for_indexing.error",
|
||||
@ -9574,10 +9574,6 @@
|
||||
"id": "ent.elasticsearch.purge_indexes.unknown_index",
|
||||
"translation": "Failed to delete an unknown index specified"
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "Your Elasticsearch channel index schema is out of date. It is recommended to regenerate your channel index.\nClick the `Rebuild Channels Index` button in [Elasticsearch section in System Console]({{.ElasticsearchSection}}) to fix the issue.\nSee Mattermost changelog for more information."
|
||||
},
|
||||
{
|
||||
"id": "model.channel_bookmark.is_valid.link_file.app_error",
|
||||
"translation": "Cannot set a link and a file in the same bookmark."
|
||||
@ -9728,7 +9724,7 @@
|
||||
},
|
||||
{
|
||||
"id": "ent.elasticsearch.purge_index.delete_failed",
|
||||
"translation": "Failed to delete an Elasticsearch index"
|
||||
"translation": "Failed to delete a search index"
|
||||
},
|
||||
{
|
||||
"id": "model.channel_bookmark.is_valid.image_url.app_error",
|
||||
@ -10267,5 +10263,137 @@
|
||||
{
|
||||
"id": "model.post.is_valid.message_length.app_error",
|
||||
"translation": "Post Message property is longer than the maximum permitted length."
|
||||
},
|
||||
{
|
||||
"id": "api.command.execute_command.deleted.error",
|
||||
"translation": "Command can't be executed in a deleted channel."
|
||||
},
|
||||
{
|
||||
"id": "api.filter_config_error",
|
||||
"translation": "Unable to filter the configuration."
|
||||
},
|
||||
{
|
||||
"id": "app.export.export_custom_emoji.mkdir.error",
|
||||
"translation": "Unable to create a directory for custom emoji images"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.cpa_group_id.app_error",
|
||||
"translation": "Cannot register Custom Profile Attributes property group"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.get_property_field.app_error",
|
||||
"translation": "Unable to get Custom Profile Attribute field"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.create_property_field.app_error",
|
||||
"translation": "Unable to create Custom Profile Attribute field"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.limit_reached.app_error",
|
||||
"translation": "Custom Profile Attributes field limit reached"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.list_property_values.app_error",
|
||||
"translation": "Unable to get custom profile attribute values"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_value_creation.app_error",
|
||||
"translation": "Cannot create property value"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_value_list.app_error",
|
||||
"translation": "Unable to retrieve property values"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_field_delete.app_error",
|
||||
"translation": "Unable to delete Custom Profile Attribute field"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_field_not_found.app_error",
|
||||
"translation": "Custom Profile Attribute field not found"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_field_update.app_error",
|
||||
"translation": "Unable to update Custom Profile Attribute field"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.search_property_fields.app_error",
|
||||
"translation": "Unable to search Custom Profile Attribute fields"
|
||||
},
|
||||
{
|
||||
"id": "app.file_info.delete_for_post_ids.app_error",
|
||||
"translation": "Failed to remove the requested files from database"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_value_update.app_error",
|
||||
"translation": "Cannot update property value"
|
||||
},
|
||||
{
|
||||
"id": "app.file_info.undelete_for_post_ids.app_error",
|
||||
"translation": "Failed to restore post file attachments."
|
||||
},
|
||||
{
|
||||
"id": "app.import.validate_user_import_data.guest_roles_conflict.error",
|
||||
"translation": "User roles are not consistent with guest status."
|
||||
},
|
||||
{
|
||||
"id": "app.post.restore_post_version.not_allowed.app_error",
|
||||
"translation": "You do not have the appropriate permissions."
|
||||
},
|
||||
{
|
||||
"id": "app.post.restore_post_version.get_single.app_error",
|
||||
"translation": "Failed to get the old post version."
|
||||
},
|
||||
{
|
||||
"id": "app.post.restore_post_version.not_an_history_item.app_error",
|
||||
"translation": "The provided post history ID does not correspond to any history item for the specified post."
|
||||
},
|
||||
{
|
||||
"id": "app.post.restore_post_version.not_valid_post_history_item.app_error",
|
||||
"translation": "The provided post history ID does not correspond to a post history item."
|
||||
},
|
||||
{
|
||||
"id": "app.role.delete.app_error",
|
||||
"translation": "Unable to delete role."
|
||||
},
|
||||
{
|
||||
"id": "ent.message_export.actiance_export.calculate_channel_exports.channel_message",
|
||||
"translation": "Exporting channel information for {{.NumChannels}} channels."
|
||||
},
|
||||
{
|
||||
"id": "ent.message_export.calculate_channel_exports.app_error",
|
||||
"translation": "Failed to calculate channel export data."
|
||||
},
|
||||
{
|
||||
"id": "ent.message_export.job_data_conversion.app_error",
|
||||
"translation": "Failed to convert a value from the job's data field."
|
||||
},
|
||||
{
|
||||
"id": "ent.message_export.actiance_export.calculate_channel_exports.activity_message",
|
||||
"translation": "Calculating channel activity: {{.NumCompleted}}/{{.NumChannels}} channels completed."
|
||||
},
|
||||
{
|
||||
"id": "model.property_field.is_valid.app_error",
|
||||
"translation": "Invalid property field: {{.FieldName}} ({{.Reason}})."
|
||||
},
|
||||
{
|
||||
"id": "model.property_value.is_valid.app_error",
|
||||
"translation": "Invalid property value: {{.FieldName}} ({{.Reason}})."
|
||||
},
|
||||
{
|
||||
"id": "api.custom_profile_attributes.license_error",
|
||||
"translation": "Your licence does not support Custom Profile Attributes."
|
||||
},
|
||||
{
|
||||
"id": "api.file.zip_file_reader.app_error",
|
||||
"translation": "Unable to get a zip file reader."
|
||||
},
|
||||
{
|
||||
"id": "app.file_info.get_by_ids.app_error",
|
||||
"translation": "Unable to get the file infos by ids for post edit history."
|
||||
},
|
||||
{
|
||||
"id": "model.link_metadata.is_valid.url_length.app_error",
|
||||
"translation": "Length of link metadata URL is {{ .Length }} characters long, which exceeds the maximum limit of {{ .MaxLength }} characters."
|
||||
}
|
||||
]
|
||||
|
@ -5006,6 +5006,10 @@
|
||||
"id": "app.custom_group.unique_name",
|
||||
"translation": "group name is not unique"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.count_property_fields.app_error",
|
||||
"translation": "Unable to count the number of fields for the custom profile attribute group"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.cpa_group_id.app_error",
|
||||
"translation": "Cannot register Custom Profile Attributes property group"
|
||||
@ -5039,16 +5043,8 @@
|
||||
"translation": "Unable to update Custom Profile Attribute field"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_value_creation.app_error",
|
||||
"translation": "Cannot create property value"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_value_list.app_error",
|
||||
"translation": "Unable to retrieve property values"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_value_update.app_error",
|
||||
"translation": "Cannot update property value"
|
||||
"id": "app.custom_profile_attributes.property_value_upsert.app_error",
|
||||
"translation": "Unable to upsert Custom Profile Attribute fields"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.search_property_fields.app_error",
|
||||
@ -9080,6 +9076,14 @@
|
||||
"id": "model.config.is_valid.message_export.global_relay.smtp_username.app_error",
|
||||
"translation": "Message export job GlobalRelaySettings.SmtpUsername must be set."
|
||||
},
|
||||
{
|
||||
"id": "model.config.is_valid.metrics_client_side_user_id.app_error",
|
||||
"translation": "Invalid client side user id: {{.Id}}"
|
||||
},
|
||||
{
|
||||
"id": "model.config.is_valid.metrics_client_side_user_ids.app_error",
|
||||
"translation": "Number of elements in ClientSideUserIds {{.CurrentLength}} is higher than maximum limit of {{.MaxLength}}."
|
||||
},
|
||||
{
|
||||
"id": "model.config.is_valid.move_thread.domain_invalid.app_error",
|
||||
"translation": "Invalid domain for move thread settings"
|
||||
@ -9430,7 +9434,7 @@
|
||||
},
|
||||
{
|
||||
"id": "model.incoming_hook.id.app_error",
|
||||
"translation": "Invalid Id."
|
||||
"translation": "Invalid Id: {{.Id}}."
|
||||
},
|
||||
{
|
||||
"id": "model.incoming_hook.parse_data.app_error",
|
||||
|
@ -8851,10 +8851,6 @@
|
||||
"id": "api.oauth.get_access_token.bad_request.app_error",
|
||||
"translation": "invalid_request : Bad request."
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "Votre schéma d'index de canal n'est pas à jour sur Elasticsearch. Il est recommandé de régénérer votre index de canal.\nCliquez sur le bouton `Reconstruire l'index des canaux` de la [section Elasticsearch dans la console système]({{.ElasticsearchSection}}) pour régler ce problème.\nConsultez le journal des modifications pour plus de détails."
|
||||
},
|
||||
{
|
||||
"id": "model.outgoing_oauth_connection.is_valid.oauth_token_url.error",
|
||||
"translation": "URL du jeton OAuth (OAuth Token URL) invalide."
|
||||
|
@ -9569,10 +9569,6 @@
|
||||
"id": "app.channel.bookmark.update_sort.app_error",
|
||||
"translation": "ブックマークをソートできませんでした。"
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "検索チャネルインデックススキーマが古くなっています。チャンネルインデックスの再生成を推奨します。\nこの問題を解決するには、[システムコンソールの Elasticsearch ページ]({{.ElasticsearchSection}}) にある `チャンネルインデックスの再構築` ボタンをクリックしてください。\n詳しくは Mattermostの変更履歴を参照してください。"
|
||||
},
|
||||
{
|
||||
"id": "app.import.import_line.null_role.error",
|
||||
"translation": "インポートデータ行に \"role\" タイプがありますが、roleオブジェクトがnullです。"
|
||||
@ -10380,5 +10376,17 @@
|
||||
{
|
||||
"id": "model.property_value.is_valid.app_error",
|
||||
"translation": "不正なプロパティ値: {{.FieldName}} {{.Reason}}。"
|
||||
},
|
||||
{
|
||||
"id": "api.command.execute_command.deleted.error",
|
||||
"translation": "削除されたチャンネルではコマンドを実行できません。"
|
||||
},
|
||||
{
|
||||
"id": "api.custom_profile_attributes.license_error",
|
||||
"translation": "あなたのライセンスはカスタムプロフィール属性をサポートしていません。"
|
||||
},
|
||||
{
|
||||
"id": "api.file.zip_file_reader.app_error",
|
||||
"translation": "ZIPファイルリーダーを取得できませんでした。"
|
||||
}
|
||||
]
|
||||
|
@ -9733,10 +9733,6 @@
|
||||
"id": "api.config.update.elasticsearch.autocomplete_cannot_be_enabled_error",
|
||||
"translation": "Kanalen automatisch aanvullen kan niet worden ingeschakeld omdat het schema van de kanaalindex verouderd is. Het wordt aanbevolen om je kanaalindex te regenereren. Zie de Mattermost changelog voor meer informatie"
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "Het schema van de index van je zoekkanalen is verouderd. We raden aan om je kanaalindex te regenereren.\nKlik op de `Rebuild Channels Index` knop in de [Elasticsearch pagina via de System Console]({{.ElasticsearchSection}}) om dit probleem op te lossen.\nZie de Mattermost changelog voor meer informatie."
|
||||
},
|
||||
{
|
||||
"id": "bleveengine.purge_list.not_implemented",
|
||||
"translation": "De functie lijst leegmaken is niet beschikbaar voor Bleve."
|
||||
@ -10402,5 +10398,17 @@
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_value_update.app_error",
|
||||
"translation": "Kan de waarde van de eigenschap niet bijwerken"
|
||||
},
|
||||
{
|
||||
"id": "api.command.execute_command.deleted.error",
|
||||
"translation": "Kan geen opdracht uitvoeren in verwijderde chatroom."
|
||||
},
|
||||
{
|
||||
"id": "api.file.zip_file_reader.app_error",
|
||||
"translation": "Kon geen zip-bestandslezer vinden."
|
||||
},
|
||||
{
|
||||
"id": "api.custom_profile_attributes.license_error",
|
||||
"translation": "Jouw licentie ondersteunt geen Aangepaste Profielattributen."
|
||||
}
|
||||
]
|
||||
|
@ -9739,10 +9739,6 @@
|
||||
"id": "api.config.update.elasticsearch.autocomplete_cannot_be_enabled_error",
|
||||
"translation": "Nie można włączyć autouzupełniania kanałów, ponieważ schemat indeksu kanałów jest nieaktualny. Zaleca się regenerację indeksu kanałów. Zobacz dziennik zmian Mattermost, aby uzyskać więcej informacji"
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "Twój schemat indeksu kanałów wyszukiwania jest nieaktualny. Zalecamy regenerację indeksu kanałów.\nKliknij przycisk `Przebuduj Indeks Kanałów` na stronie [Elasticsearch via the System Console]({{.ElasticsearchSection}}), aby naprawić ten błąd.\nZobacz logi Mattermost, aby uzyskać więcej informacji."
|
||||
},
|
||||
{
|
||||
"id": "bleveengine.purge_list.not_implemented",
|
||||
"translation": "Funkcja listy czyszczenia nie jest dostępna dla Bleve."
|
||||
|
@ -9303,10 +9303,6 @@
|
||||
"id": "app.acknowledgement.getforpost.get.app_error",
|
||||
"translation": "Não foi possível obter confirmação de recebimento da publicação."
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "O esquema do índice de canais do Elasticsearch está desatualizado. É recomendável que você gere novamente o índice de canais.\nClique no botão `Reconstruir índices de Canais` na [seção Elasticsearch no System Console]({{.ElasticsearchSection}}) para corrigir o problema.\nConsulte o registro de alterações do Mattermost para obter mais informações."
|
||||
},
|
||||
{
|
||||
"id": "app.compile_report_chunks.unsupported_format",
|
||||
"translation": "Formato de relatório não suportado."
|
||||
|
@ -9803,10 +9803,6 @@
|
||||
"id": "api.user.auth_switch.not_available.login_disabled.app_error",
|
||||
"translation": "Передача аутентификации недоступна, так как не включен ни вход по электронной почте, ни вход по имени пользователя."
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "Схема поискового индекса ваших каналов устарела. Мы рекомендуем регенерировать индекс ваших каналов.\nНажмите кнопку `Ребилд индексов каналов` на [странице Elasticsearch через Системную консоль]({{.ElasticsearchSection}}), чтобы исправить эту проблему.\nБолее подробную информацию смотри в журнале изменений Mattermost."
|
||||
},
|
||||
{
|
||||
"id": "api.post.check_for_out_of_team_mentions.message.multiple",
|
||||
"translation": "@{{.Usernames}} и @{{.LastUsername}} не получили уведомлений об этом упоминании, потому что они не являются участниками этой команды."
|
||||
|
@ -9733,10 +9733,6 @@
|
||||
"id": "model.channel_bookmark.is_valid.update_at.app_error",
|
||||
"translation": "\"Update at\" måste vara en giltig tid."
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "Ditt Elasticsearch-indexschema för kanaler är föråldrat. En omindexering av kanal-index rekommenderas.\nKlicka på knappen `Rebuild Channels Index` i [Elasticsearch-avsnittet i systemkonsolen] ({{.ElasticsearchSection}}) för att åtgärda problemet.\nSe Mattermost changelog för mer information."
|
||||
},
|
||||
{
|
||||
"id": "api.config.update.elasticsearch.autocomplete_cannot_be_enabled_error",
|
||||
"translation": "Autokomplettering av kanaler kan inte aktiveras eftersom indexschemat för kanaler är föråldrat. Vi rekommenderar att du omindexerar ditt kanalindex. Se Mattermost changelog för mer information"
|
||||
|
@ -9705,10 +9705,6 @@
|
||||
"id": "api.templates.ip_filters_changed_footer.title",
|
||||
"translation": "Çalışma alanınıza erişmekte sorun mu yaşıyorsunuz?"
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "Arama kanal dizini şemanız güncel değil. Kanal dizininizi yeniden oluşturmanız önerilir.\nSorunu çözmek için [Sistem panosundaki Elasticsearch bölümünden] ({{.ElasticsearchSection}}) `Kanal dizinini yeniden oluştur` düğmesine tıklayın.\nAyrıntılı bilgi almak için Mattermost değişiklik günlüğüne bakın."
|
||||
},
|
||||
{
|
||||
"id": "app.user.update_active.user_limit.exceeded",
|
||||
"translation": "Kullanıcı etkinleştirilemedi. Sunucu güvenli kullanıcı sayısı sınırını aşıyor. Yöneticiniz ile görüşün: ERROR_SAFETY_LIMITS_EXCEEDED."
|
||||
@ -10398,5 +10394,9 @@
|
||||
{
|
||||
"id": "model.property_value.is_valid.app_error",
|
||||
"translation": "Özellik değeri geçersiz: {{.FieldName}} ({{.Reason}})."
|
||||
},
|
||||
{
|
||||
"id": "api.command.execute_command.deleted.error",
|
||||
"translation": "Silinmiş bir kanalda komut yürütülemez."
|
||||
}
|
||||
]
|
||||
|
@ -8279,10 +8279,6 @@
|
||||
"id": "app.channel.autofollow.app_error",
|
||||
"translation": "Не вдалося оновити участь у обговореннях для згаданого користувача"
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "Схема індексу каналу пошуку застаріла. Ми рекомендуємо регенерувати індекс вашого каналу.\nНатисніть кнопку `Перебудувати індекс каналів` на [сторінці Elasticsearch через Системну консоль]({{.ElasticsearchSection}}), щоб вирішити цю проблему.\nДетальнішу інформацію ви можете переглянути у журналі змін Mattermost."
|
||||
},
|
||||
{
|
||||
"id": "api.user.create_user.user_limits.exceeded",
|
||||
"translation": "Неможливо створити користувача. Сервер перевищує ліміт безпечних користувачів. Зверніться до свого адміністратора з повідомленням: ERROR_SAFETY_LIMITS_EXCEEDED."
|
||||
@ -10342,5 +10338,17 @@
|
||||
{
|
||||
"id": "app.custom_profile_attributes.limit_reached.app_error",
|
||||
"translation": "Досягнуто ліміт поля \"Атрибути профілю користувача\""
|
||||
},
|
||||
{
|
||||
"id": "api.command.execute_command.deleted.error",
|
||||
"translation": "Неможливо виконати команду у видаленому каналі."
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_field_not_found.app_error",
|
||||
"translation": "Користувацьке поле атрибуту профілю не знайдено"
|
||||
},
|
||||
{
|
||||
"id": "app.custom_profile_attributes.property_field_update.app_error",
|
||||
"translation": "Не вдається оновити поле атрибуту користувацького профілю"
|
||||
}
|
||||
]
|
||||
|
@ -9709,10 +9709,6 @@
|
||||
"id": "app.channel.bookmark.update_sort.app_error",
|
||||
"translation": "无法排序书签。"
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "您的 Elasticsearch 频道索引表已过期。推荐您重新生成频道索引。\n在[系统控制台中的 Elasticsearch 部分]({{.ElasticsearchSection}})点击`重建频道索引`以解决问题。\n查看 Mattermost 更新日志以了解更多信息。"
|
||||
},
|
||||
{
|
||||
"id": "ent.elasticsearch.purge_index.delete_failed",
|
||||
"translation": "删除一条 Elasticsearch 索引失败"
|
||||
@ -10380,5 +10376,17 @@
|
||||
{
|
||||
"id": "ent.message_export.job_data_conversion.app_error",
|
||||
"translation": "无法转换任务数据字段中的值。"
|
||||
},
|
||||
{
|
||||
"id": "api.command.execute_command.deleted.error",
|
||||
"translation": "不能在已删除的频道中执行命令。"
|
||||
},
|
||||
{
|
||||
"id": "api.custom_profile_attributes.license_error",
|
||||
"translation": "您的授权不支持自定义个人资料属性。"
|
||||
},
|
||||
{
|
||||
"id": "api.file.zip_file_reader.app_error",
|
||||
"translation": "无法获取 zip 文件读取器。"
|
||||
}
|
||||
]
|
||||
|
@ -9683,10 +9683,6 @@
|
||||
"id": "api.user.create_user.user_limits.exceeded",
|
||||
"translation": "無法建立使用者。使用者數量超過伺服器安全限制。請聯絡您的管理員,並附上錯誤代碼:ERROR_SAFETY_LIMITS_EXCEEDED。"
|
||||
},
|
||||
{
|
||||
"id": "app.channel.elasticsearch_channel_index.notify_admin.message",
|
||||
"translation": "您的 Elasticsearch 頻道索引架構已過期,建議重新生成您的頻道索引。\n請點ㄧ下 [系統管理後台的 Elasticsearch]{{.ElasticsearchSection}} 中的「重建頻道索引」按鈕來修復此問題。 \n有關更多資訊,請參閱 Mattermost 變更記錄。"
|
||||
},
|
||||
{
|
||||
"id": "ent.elasticsearch.purge_index.delete_failed",
|
||||
"translation": "刪除一條 Elasticsearch 索引失敗"
|
||||
|
@ -439,6 +439,7 @@ type ServiceSettings struct {
|
||||
MaximumPayloadSizeBytes *int64 `access:"environment_file_storage,write_restrictable,cloud_restrictable"`
|
||||
MaximumURLLength *int `access:"environment_file_storage,write_restrictable,cloud_restrictable"`
|
||||
ScheduledPosts *bool `access:"site_posts"`
|
||||
EnableWebHubChannelIteration *bool `access:"write_restrictable,cloud_restrictable"` // telemetry: none
|
||||
}
|
||||
|
||||
var MattermostGiphySdkKey string
|
||||
@ -962,6 +963,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) {
|
||||
if s.ScheduledPosts == nil {
|
||||
s.ScheduledPosts = NewPointer(true)
|
||||
}
|
||||
|
||||
if s.EnableWebHubChannelIteration == nil {
|
||||
s.EnableWebHubChannelIteration = NewPointer(false)
|
||||
}
|
||||
}
|
||||
|
||||
type CacheSettings struct {
|
||||
@ -1076,6 +1081,7 @@ type MetricsSettings struct {
|
||||
ListenAddress *string `access:"environment_performance_monitoring,write_restrictable,cloud_restrictable"` // telemetry: none
|
||||
EnableClientMetrics *bool `access:"environment_performance_monitoring,write_restrictable,cloud_restrictable"`
|
||||
EnableNotificationMetrics *bool `access:"environment_performance_monitoring,write_restrictable,cloud_restrictable"`
|
||||
ClientSideUserIds []string `access:"environment_performance_monitoring,write_restrictable,cloud_restrictable"` // telemetry: none
|
||||
}
|
||||
|
||||
func (s *MetricsSettings) SetDefaults() {
|
||||
@ -1098,6 +1104,23 @@ func (s *MetricsSettings) SetDefaults() {
|
||||
if s.EnableNotificationMetrics == nil {
|
||||
s.EnableNotificationMetrics = NewPointer(true)
|
||||
}
|
||||
|
||||
if s.ClientSideUserIds == nil {
|
||||
s.ClientSideUserIds = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MetricsSettings) isValid() *AppError {
|
||||
const maxLength = 5
|
||||
if len(s.ClientSideUserIds) > maxLength {
|
||||
return NewAppError("MetricsSettings.IsValid", "model.config.is_valid.metrics_client_side_user_ids.app_error", map[string]any{"MaxLength": maxLength, "CurrentLength": len(s.ClientSideUserIds)}, "", http.StatusBadRequest)
|
||||
}
|
||||
for _, id := range s.ClientSideUserIds {
|
||||
if !IsValidId(id) {
|
||||
return NewAppError("MetricsSettings.IsValid", "model.config.is_valid.metrics_client_side_user_id.app_error", map[string]any{"Id": id}, "", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ExperimentalSettings struct {
|
||||
@ -3806,6 +3829,10 @@ func (o *Config) IsValid() *AppError {
|
||||
return NewAppError("Config.IsValid", "model.config.is_valid.cluster_email_batching.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if appErr := o.MetricsSettings.isValid(); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
if appErr := o.CacheSettings.isValid(); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
@ -4,3 +4,19 @@
|
||||
package model
|
||||
|
||||
const CustomProfileAttributesPropertyGroupName = "custom_profile_attributes"
|
||||
|
||||
const CustomProfileAttributesPropertyAttrsSortOrder = "sort_order"
|
||||
|
||||
func CustomProfileAttributesPropertySortOrder(p *PropertyField) int {
|
||||
value, ok := p.Attrs[CustomProfileAttributesPropertyAttrsSortOrder]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
order, ok := value.(float64)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
return int(order)
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ type IncomingWebhooksWithCount struct {
|
||||
|
||||
func (o *IncomingWebhook) IsValid() *AppError {
|
||||
if !IsValidId(o.Id) {
|
||||
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.id.app_error", nil, "", http.StatusBadRequest)
|
||||
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.id.app_error", map[string]any{"Id": o.Id}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if o.CreateAt == 0 {
|
||||
|
@ -4,6 +4,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@ -98,7 +99,7 @@ func (pf *PropertyField) SanitizeInput() {
|
||||
type PropertyFieldPatch struct {
|
||||
Name *string `json:"name"`
|
||||
Type *PropertyFieldType `json:"type"`
|
||||
Attrs *map[string]any `json:"attrs"`
|
||||
Attrs *StringInterface `json:"attrs"`
|
||||
TargetID *string `json:"target_id"`
|
||||
TargetType *string `json:"target_type"`
|
||||
}
|
||||
@ -141,11 +142,39 @@ func (pf *PropertyField) Patch(patch *PropertyFieldPatch) {
|
||||
}
|
||||
}
|
||||
|
||||
type PropertyFieldSearchCursor struct {
|
||||
PropertyFieldID string
|
||||
CreateAt int64
|
||||
}
|
||||
|
||||
func (p PropertyFieldSearchCursor) IsEmpty() bool {
|
||||
return p.PropertyFieldID == "" && p.CreateAt == 0
|
||||
}
|
||||
|
||||
func (p PropertyFieldSearchCursor) IsValid() error {
|
||||
if p.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.CreateAt <= 0 {
|
||||
return errors.New("create at cannot be negative or zero")
|
||||
}
|
||||
|
||||
if !IsValidId(p.PropertyFieldID) {
|
||||
return errors.New("property field id is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PropertyFieldSearchOpts struct {
|
||||
GroupID string
|
||||
TargetType string
|
||||
TargetID string
|
||||
IncludeDeleted bool
|
||||
Page int
|
||||
Cursor PropertyFieldSearchCursor
|
||||
PerPage int
|
||||
}
|
||||
|
||||
func (pf *PropertyField) GetAttr(key string) any {
|
||||
return pf.Attrs[key]
|
||||
}
|
||||
|
218
server/public/model/property_field_test.go
Normal file
218
server/public/model/property_field_test.go
Normal file
@ -0,0 +1,218 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPropertyField_PreSave(t *testing.T) {
|
||||
t.Run("sets ID if empty", func(t *testing.T) {
|
||||
pf := &PropertyField{}
|
||||
pf.PreSave()
|
||||
assert.NotEmpty(t, pf.ID)
|
||||
assert.Len(t, pf.ID, 26) // Length of NewId()
|
||||
})
|
||||
|
||||
t.Run("keeps existing ID", func(t *testing.T) {
|
||||
pf := &PropertyField{ID: "existing_id"}
|
||||
pf.PreSave()
|
||||
assert.Equal(t, "existing_id", pf.ID)
|
||||
})
|
||||
|
||||
t.Run("sets CreateAt if zero", func(t *testing.T) {
|
||||
pf := &PropertyField{}
|
||||
pf.PreSave()
|
||||
assert.NotZero(t, pf.CreateAt)
|
||||
})
|
||||
|
||||
t.Run("sets UpdateAt equal to CreateAt", func(t *testing.T) {
|
||||
pf := &PropertyField{}
|
||||
pf.PreSave()
|
||||
assert.Equal(t, pf.CreateAt, pf.UpdateAt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPropertyField_IsValid(t *testing.T) {
|
||||
t.Run("valid field", func(t *testing.T) {
|
||||
pf := &PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: NewId(),
|
||||
Name: "test field",
|
||||
Type: PropertyFieldTypeText,
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.NoError(t, pf.IsValid())
|
||||
})
|
||||
|
||||
t.Run("invalid ID", func(t *testing.T) {
|
||||
pf := &PropertyField{
|
||||
ID: "invalid",
|
||||
GroupID: NewId(),
|
||||
Name: "test field",
|
||||
Type: PropertyFieldTypeText,
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.Error(t, pf.IsValid())
|
||||
})
|
||||
|
||||
t.Run("invalid GroupID", func(t *testing.T) {
|
||||
pf := &PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: "invalid",
|
||||
Name: "test field",
|
||||
Type: PropertyFieldTypeText,
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.Error(t, pf.IsValid())
|
||||
})
|
||||
|
||||
t.Run("empty name", func(t *testing.T) {
|
||||
pf := &PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: NewId(),
|
||||
Name: "",
|
||||
Type: PropertyFieldTypeText,
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.Error(t, pf.IsValid())
|
||||
})
|
||||
|
||||
t.Run("invalid type", func(t *testing.T) {
|
||||
pf := &PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: NewId(),
|
||||
Name: "test field",
|
||||
Type: "invalid",
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.Error(t, pf.IsValid())
|
||||
})
|
||||
|
||||
t.Run("zero CreateAt", func(t *testing.T) {
|
||||
pf := &PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: NewId(),
|
||||
Name: "test field",
|
||||
Type: PropertyFieldTypeText,
|
||||
CreateAt: 0,
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.Error(t, pf.IsValid())
|
||||
})
|
||||
|
||||
t.Run("zero UpdateAt", func(t *testing.T) {
|
||||
pf := &PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: NewId(),
|
||||
Name: "test field",
|
||||
Type: PropertyFieldTypeText,
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: 0,
|
||||
}
|
||||
require.Error(t, pf.IsValid())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPropertyField_SanitizeInput(t *testing.T) {
|
||||
t.Run("trims spaces from name", func(t *testing.T) {
|
||||
pf := &PropertyField{Name: " test field "}
|
||||
pf.SanitizeInput()
|
||||
assert.Equal(t, "test field", pf.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPropertyField_Patch(t *testing.T) {
|
||||
t.Run("patches all fields", func(t *testing.T) {
|
||||
pf := &PropertyField{
|
||||
Name: "original name",
|
||||
Type: PropertyFieldTypeText,
|
||||
TargetID: "original_target",
|
||||
TargetType: "original_type",
|
||||
}
|
||||
|
||||
patch := &PropertyFieldPatch{
|
||||
Name: NewPointer("new name"),
|
||||
Type: NewPointer(PropertyFieldTypeSelect),
|
||||
TargetID: NewPointer("new_target"),
|
||||
TargetType: NewPointer("new_type"),
|
||||
Attrs: &StringInterface{"key": "value"},
|
||||
}
|
||||
|
||||
pf.Patch(patch)
|
||||
|
||||
assert.Equal(t, "new name", pf.Name)
|
||||
assert.Equal(t, PropertyFieldTypeSelect, pf.Type)
|
||||
assert.Equal(t, "new_target", pf.TargetID)
|
||||
assert.Equal(t, "new_type", pf.TargetType)
|
||||
assert.EqualValues(t, StringInterface{"key": "value"}, pf.Attrs)
|
||||
})
|
||||
|
||||
t.Run("patches only specified fields", func(t *testing.T) {
|
||||
pf := &PropertyField{
|
||||
Name: "original name",
|
||||
Type: PropertyFieldTypeText,
|
||||
TargetID: "original_target",
|
||||
TargetType: "original_type",
|
||||
}
|
||||
|
||||
patch := &PropertyFieldPatch{
|
||||
Name: NewPointer("new name"),
|
||||
}
|
||||
|
||||
pf.Patch(patch)
|
||||
|
||||
assert.Equal(t, "new name", pf.Name)
|
||||
assert.Equal(t, PropertyFieldTypeText, pf.Type)
|
||||
assert.Equal(t, "original_target", pf.TargetID)
|
||||
assert.Equal(t, "original_type", pf.TargetType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPropertyFieldSearchCursor_IsValid(t *testing.T) {
|
||||
t.Run("empty cursor is valid", func(t *testing.T) {
|
||||
cursor := PropertyFieldSearchCursor{}
|
||||
assert.NoError(t, cursor.IsValid())
|
||||
})
|
||||
|
||||
t.Run("valid cursor", func(t *testing.T) {
|
||||
cursor := PropertyFieldSearchCursor{
|
||||
PropertyFieldID: NewId(),
|
||||
CreateAt: GetMillis(),
|
||||
}
|
||||
assert.NoError(t, cursor.IsValid())
|
||||
})
|
||||
|
||||
t.Run("invalid PropertyFieldID", func(t *testing.T) {
|
||||
cursor := PropertyFieldSearchCursor{
|
||||
PropertyFieldID: "invalid",
|
||||
CreateAt: GetMillis(),
|
||||
}
|
||||
assert.Error(t, cursor.IsValid())
|
||||
})
|
||||
|
||||
t.Run("zero CreateAt", func(t *testing.T) {
|
||||
cursor := PropertyFieldSearchCursor{
|
||||
PropertyFieldID: NewId(),
|
||||
CreateAt: 0,
|
||||
}
|
||||
assert.Error(t, cursor.IsValid())
|
||||
})
|
||||
|
||||
t.Run("negative CreateAt", func(t *testing.T) {
|
||||
cursor := PropertyFieldSearchCursor{
|
||||
PropertyFieldID: NewId(),
|
||||
CreateAt: -1,
|
||||
}
|
||||
assert.Error(t, cursor.IsValid())
|
||||
})
|
||||
}
|
@ -6,6 +6,8 @@ package model
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type PropertyValue struct {
|
||||
@ -63,12 +65,36 @@ func (pv *PropertyValue) IsValid() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type PropertyValueSearchCursor struct {
|
||||
PropertyValueID string
|
||||
CreateAt int64
|
||||
}
|
||||
|
||||
func (p PropertyValueSearchCursor) IsEmpty() bool {
|
||||
return p.PropertyValueID == "" && p.CreateAt == 0
|
||||
}
|
||||
|
||||
func (p PropertyValueSearchCursor) IsValid() error {
|
||||
if p.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.CreateAt <= 0 {
|
||||
return errors.New("create at cannot be negative or zero")
|
||||
}
|
||||
|
||||
if !IsValidId(p.PropertyValueID) {
|
||||
return errors.New("property field id is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PropertyValueSearchOpts struct {
|
||||
GroupID string
|
||||
TargetType string
|
||||
TargetID string
|
||||
FieldID string
|
||||
IncludeDeleted bool
|
||||
Page int
|
||||
Cursor PropertyValueSearchCursor
|
||||
PerPage int
|
||||
}
|
||||
|
186
server/public/model/property_value_test.go
Normal file
186
server/public/model/property_value_test.go
Normal file
@ -0,0 +1,186 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPropertyValue_PreSave(t *testing.T) {
|
||||
t.Run("sets ID if empty", func(t *testing.T) {
|
||||
pv := &PropertyValue{}
|
||||
pv.PreSave()
|
||||
assert.NotEmpty(t, pv.ID)
|
||||
assert.Len(t, pv.ID, 26) // Length of NewId()
|
||||
})
|
||||
|
||||
t.Run("keeps existing ID", func(t *testing.T) {
|
||||
pv := &PropertyValue{ID: "existing_id"}
|
||||
pv.PreSave()
|
||||
assert.Equal(t, "existing_id", pv.ID)
|
||||
})
|
||||
|
||||
t.Run("sets CreateAt if zero", func(t *testing.T) {
|
||||
pv := &PropertyValue{}
|
||||
pv.PreSave()
|
||||
assert.NotZero(t, pv.CreateAt)
|
||||
})
|
||||
|
||||
t.Run("sets UpdateAt equal to CreateAt", func(t *testing.T) {
|
||||
pv := &PropertyValue{}
|
||||
pv.PreSave()
|
||||
assert.Equal(t, pv.CreateAt, pv.UpdateAt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPropertyValue_IsValid(t *testing.T) {
|
||||
t.Run("valid value", func(t *testing.T) {
|
||||
value := json.RawMessage(`{"test": "value"}`)
|
||||
pv := &PropertyValue{
|
||||
ID: NewId(),
|
||||
TargetID: NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: NewId(),
|
||||
FieldID: NewId(),
|
||||
Value: value,
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.NoError(t, pv.IsValid())
|
||||
})
|
||||
|
||||
t.Run("invalid ID", func(t *testing.T) {
|
||||
pv := &PropertyValue{
|
||||
ID: "invalid",
|
||||
TargetID: NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: NewId(),
|
||||
FieldID: NewId(),
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.Error(t, pv.IsValid())
|
||||
})
|
||||
|
||||
t.Run("invalid TargetID", func(t *testing.T) {
|
||||
pv := &PropertyValue{
|
||||
ID: NewId(),
|
||||
TargetID: "invalid",
|
||||
TargetType: "test_type",
|
||||
GroupID: NewId(),
|
||||
FieldID: NewId(),
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.Error(t, pv.IsValid())
|
||||
})
|
||||
|
||||
t.Run("empty TargetType", func(t *testing.T) {
|
||||
pv := &PropertyValue{
|
||||
ID: NewId(),
|
||||
TargetID: NewId(),
|
||||
TargetType: "",
|
||||
GroupID: NewId(),
|
||||
FieldID: NewId(),
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.Error(t, pv.IsValid())
|
||||
})
|
||||
|
||||
t.Run("invalid GroupID", func(t *testing.T) {
|
||||
pv := &PropertyValue{
|
||||
ID: NewId(),
|
||||
TargetID: NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: "invalid",
|
||||
FieldID: NewId(),
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.Error(t, pv.IsValid())
|
||||
})
|
||||
|
||||
t.Run("invalid FieldID", func(t *testing.T) {
|
||||
pv := &PropertyValue{
|
||||
ID: NewId(),
|
||||
TargetID: NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: NewId(),
|
||||
FieldID: "invalid",
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.Error(t, pv.IsValid())
|
||||
})
|
||||
|
||||
t.Run("zero CreateAt", func(t *testing.T) {
|
||||
pv := &PropertyValue{
|
||||
ID: NewId(),
|
||||
TargetID: NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: NewId(),
|
||||
FieldID: NewId(),
|
||||
CreateAt: 0,
|
||||
UpdateAt: GetMillis(),
|
||||
}
|
||||
require.Error(t, pv.IsValid())
|
||||
})
|
||||
|
||||
t.Run("zero UpdateAt", func(t *testing.T) {
|
||||
pv := &PropertyValue{
|
||||
ID: NewId(),
|
||||
TargetID: NewId(),
|
||||
TargetType: "test_type",
|
||||
GroupID: NewId(),
|
||||
FieldID: NewId(),
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: 0,
|
||||
}
|
||||
require.Error(t, pv.IsValid())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPropertyValueSearchCursor_IsValid(t *testing.T) {
|
||||
t.Run("empty cursor is valid", func(t *testing.T) {
|
||||
cursor := PropertyValueSearchCursor{}
|
||||
assert.NoError(t, cursor.IsValid())
|
||||
})
|
||||
|
||||
t.Run("valid cursor", func(t *testing.T) {
|
||||
cursor := PropertyValueSearchCursor{
|
||||
PropertyValueID: NewId(),
|
||||
CreateAt: GetMillis(),
|
||||
}
|
||||
assert.NoError(t, cursor.IsValid())
|
||||
})
|
||||
|
||||
t.Run("invalid PropertyValueID", func(t *testing.T) {
|
||||
cursor := PropertyValueSearchCursor{
|
||||
PropertyValueID: "invalid",
|
||||
CreateAt: GetMillis(),
|
||||
}
|
||||
assert.Error(t, cursor.IsValid())
|
||||
})
|
||||
|
||||
t.Run("zero CreateAt", func(t *testing.T) {
|
||||
cursor := PropertyValueSearchCursor{
|
||||
PropertyValueID: NewId(),
|
||||
CreateAt: 0,
|
||||
}
|
||||
assert.Error(t, cursor.IsValid())
|
||||
})
|
||||
|
||||
t.Run("negative CreateAt", func(t *testing.T) {
|
||||
cursor := PropertyValueSearchCursor{
|
||||
PropertyValueID: NewId(),
|
||||
CreateAt: -1,
|
||||
}
|
||||
assert.Error(t, cursor.IsValid())
|
||||
})
|
||||
}
|
@ -94,6 +94,10 @@ const (
|
||||
WebsocketScheduledPostCreated WebsocketEventType = "scheduled_post_created"
|
||||
WebsocketScheduledPostUpdated WebsocketEventType = "scheduled_post_updated"
|
||||
WebsocketScheduledPostDeleted WebsocketEventType = "scheduled_post_deleted"
|
||||
WebsocketEventCPAFieldCreated WebsocketEventType = "custom_profile_attributes_field_created"
|
||||
WebsocketEventCPAFieldUpdated WebsocketEventType = "custom_profile_attributes_field_updated"
|
||||
WebsocketEventCPAFieldDeleted WebsocketEventType = "custom_profile_attributes_field_deleted"
|
||||
WebsocketEventCPAValuesUpdated WebsocketEventType = "custom_profile_attributes_values_updated"
|
||||
|
||||
WebSocketMsgTypeResponse = "response"
|
||||
WebSocketMsgTypeEvent = "event"
|
||||
|
@ -108,11 +108,19 @@ func SanitizeDataSource(driverName, dataSource string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
u.User = url.UserPassword("****", "****")
|
||||
|
||||
// Remove username and password from query string
|
||||
params := u.Query()
|
||||
params.Del("user")
|
||||
params.Del("password")
|
||||
u.RawQuery = params.Encode()
|
||||
return u.String(), nil
|
||||
|
||||
// Unescape the URL to make it human-readable
|
||||
out, err := url.QueryUnescape(u.String())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
case model.DatabaseDriverMysql:
|
||||
cfg, err := mysql.ParseDSN(dataSource)
|
||||
if err != nil {
|
||||
|
@ -72,13 +72,21 @@ func TestSanitizeDataSource(t *testing.T) {
|
||||
Original string
|
||||
Sanitized string
|
||||
}{
|
||||
{
|
||||
"",
|
||||
"//****:****@",
|
||||
},
|
||||
{
|
||||
"postgres://mmuser:mostest@localhost",
|
||||
"postgres://****:****@localhost",
|
||||
},
|
||||
{
|
||||
"postgres://mmuser:mostest@localhost/dummy?sslmode=disable",
|
||||
"postgres://%2A%2A%2A%2A:%2A%2A%2A%2A@localhost/dummy?sslmode=disable",
|
||||
"postgres://****:****@localhost/dummy?sslmode=disable",
|
||||
},
|
||||
{
|
||||
"postgres://localhost/dummy?sslmode=disable&user=mmuser&password=mostest",
|
||||
"postgres://%2A%2A%2A%2A:%2A%2A%2A%2A@localhost/dummy?sslmode=disable",
|
||||
"postgres://****:****@localhost/dummy?sslmode=disable",
|
||||
},
|
||||
}
|
||||
driver := model.DatabaseDriverPostgres
|
||||
|
@ -45,7 +45,7 @@
|
||||
"html-to-react": "1.6.0",
|
||||
"inobounce": "0.2.1",
|
||||
"ipaddr.js": "2.1.0",
|
||||
"katex": "0.16.10",
|
||||
"katex": "0.16.21",
|
||||
"localforage": "1.10.0",
|
||||
"localforage-observable": "2.1.1",
|
||||
"lodash": "4.17.21",
|
||||
|
@ -0,0 +1,470 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/AdminConsole/ClientSideUserIdsSetting initial state with multiple items 1`] = `
|
||||
<ClientSideUserIdsSetting
|
||||
disabled={false}
|
||||
id="MySetting"
|
||||
onChange={[MockFunction]}
|
||||
setByEnv={false}
|
||||
value={
|
||||
Array [
|
||||
"userid1",
|
||||
"userid2",
|
||||
"id3",
|
||||
]
|
||||
}
|
||||
>
|
||||
<Memo(Settings)
|
||||
helpText={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Set the user ids you want to track for client side metrics. Separate values with a comma."
|
||||
id="admin.customization.clientSideUserIdsDesc"
|
||||
/>
|
||||
}
|
||||
inputId="MySetting"
|
||||
label={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Client side user ids:"
|
||||
id="admin.customization.clientSideUserIds"
|
||||
/>
|
||||
}
|
||||
setByEnv={false}
|
||||
>
|
||||
<div
|
||||
className="form-group"
|
||||
data-testid="MySetting"
|
||||
>
|
||||
<label
|
||||
className="control-label col-sm-4"
|
||||
htmlFor="MySetting"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Client side user ids:"
|
||||
id="admin.customization.clientSideUserIds"
|
||||
>
|
||||
<span>
|
||||
Client side user ids:
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</label>
|
||||
<div
|
||||
className="col-sm-8"
|
||||
>
|
||||
<LocalizedPlaceholderInput
|
||||
className="form-control"
|
||||
disabled={false}
|
||||
id="MySetting"
|
||||
onChange={[Function]}
|
||||
placeholder={
|
||||
Object {
|
||||
"defaultMessage": "E.g.: \\"userid1,userid2\\"",
|
||||
"id": "admin.customization.clientSideUserIdsPlaceholder",
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
value="userid1,userid2,id3"
|
||||
>
|
||||
<input
|
||||
className="form-control"
|
||||
disabled={false}
|
||||
id="MySetting"
|
||||
onChange={[Function]}
|
||||
placeholder="E.g.: \\"userid1,userid2\\""
|
||||
type="text"
|
||||
value="userid1,userid2,id3"
|
||||
/>
|
||||
</LocalizedPlaceholderInput>
|
||||
<div
|
||||
className="help-text"
|
||||
data-testid="MySettinghelp-text"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Set the user ids you want to track for client side metrics. Separate values with a comma."
|
||||
id="admin.customization.clientSideUserIdsDesc"
|
||||
>
|
||||
<span>
|
||||
Set the user ids you want to track for client side metrics. Separate values with a comma.
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Memo(Settings)>
|
||||
</ClientSideUserIdsSetting>
|
||||
`;
|
||||
|
||||
exports[`components/AdminConsole/ClientSideUserIdsSetting initial state with no items 1`] = `
|
||||
<ClientSideUserIdsSetting
|
||||
disabled={false}
|
||||
id="MySetting"
|
||||
onChange={[MockFunction]}
|
||||
setByEnv={false}
|
||||
value={Array []}
|
||||
>
|
||||
<Memo(Settings)
|
||||
helpText={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Set the user ids you want to track for client side metrics. Separate values with a comma."
|
||||
id="admin.customization.clientSideUserIdsDesc"
|
||||
/>
|
||||
}
|
||||
inputId="MySetting"
|
||||
label={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Client side user ids:"
|
||||
id="admin.customization.clientSideUserIds"
|
||||
/>
|
||||
}
|
||||
setByEnv={false}
|
||||
>
|
||||
<div
|
||||
className="form-group"
|
||||
data-testid="MySetting"
|
||||
>
|
||||
<label
|
||||
className="control-label col-sm-4"
|
||||
htmlFor="MySetting"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Client side user ids:"
|
||||
id="admin.customization.clientSideUserIds"
|
||||
>
|
||||
<span>
|
||||
Client side user ids:
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</label>
|
||||
<div
|
||||
className="col-sm-8"
|
||||
>
|
||||
<LocalizedPlaceholderInput
|
||||
className="form-control"
|
||||
disabled={false}
|
||||
id="MySetting"
|
||||
onChange={[Function]}
|
||||
placeholder={
|
||||
Object {
|
||||
"defaultMessage": "E.g.: \\"userid1,userid2\\"",
|
||||
"id": "admin.customization.clientSideUserIdsPlaceholder",
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
value=""
|
||||
>
|
||||
<input
|
||||
className="form-control"
|
||||
disabled={false}
|
||||
id="MySetting"
|
||||
onChange={[Function]}
|
||||
placeholder="E.g.: \\"userid1,userid2\\""
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</LocalizedPlaceholderInput>
|
||||
<div
|
||||
className="help-text"
|
||||
data-testid="MySettinghelp-text"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Set the user ids you want to track for client side metrics. Separate values with a comma."
|
||||
id="admin.customization.clientSideUserIdsDesc"
|
||||
>
|
||||
<span>
|
||||
Set the user ids you want to track for client side metrics. Separate values with a comma.
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Memo(Settings)>
|
||||
</ClientSideUserIdsSetting>
|
||||
`;
|
||||
|
||||
exports[`components/AdminConsole/ClientSideUserIdsSetting initial state with one item 1`] = `
|
||||
<ClientSideUserIdsSetting
|
||||
disabled={false}
|
||||
id="MySetting"
|
||||
onChange={[MockFunction]}
|
||||
setByEnv={false}
|
||||
value={
|
||||
Array [
|
||||
"userid1",
|
||||
]
|
||||
}
|
||||
>
|
||||
<Memo(Settings)
|
||||
helpText={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Set the user ids you want to track for client side metrics. Separate values with a comma."
|
||||
id="admin.customization.clientSideUserIdsDesc"
|
||||
/>
|
||||
}
|
||||
inputId="MySetting"
|
||||
label={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Client side user ids:"
|
||||
id="admin.customization.clientSideUserIds"
|
||||
/>
|
||||
}
|
||||
setByEnv={false}
|
||||
>
|
||||
<div
|
||||
className="form-group"
|
||||
data-testid="MySetting"
|
||||
>
|
||||
<label
|
||||
className="control-label col-sm-4"
|
||||
htmlFor="MySetting"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Client side user ids:"
|
||||
id="admin.customization.clientSideUserIds"
|
||||
>
|
||||
<span>
|
||||
Client side user ids:
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</label>
|
||||
<div
|
||||
className="col-sm-8"
|
||||
>
|
||||
<LocalizedPlaceholderInput
|
||||
className="form-control"
|
||||
disabled={false}
|
||||
id="MySetting"
|
||||
onChange={[Function]}
|
||||
placeholder={
|
||||
Object {
|
||||
"defaultMessage": "E.g.: \\"userid1,userid2\\"",
|
||||
"id": "admin.customization.clientSideUserIdsPlaceholder",
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
value="userid1"
|
||||
>
|
||||
<input
|
||||
className="form-control"
|
||||
disabled={false}
|
||||
id="MySetting"
|
||||
onChange={[Function]}
|
||||
placeholder="E.g.: \\"userid1,userid2\\""
|
||||
type="text"
|
||||
value="userid1"
|
||||
/>
|
||||
</LocalizedPlaceholderInput>
|
||||
<div
|
||||
className="help-text"
|
||||
data-testid="MySettinghelp-text"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Set the user ids you want to track for client side metrics. Separate values with a comma."
|
||||
id="admin.customization.clientSideUserIdsDesc"
|
||||
>
|
||||
<span>
|
||||
Set the user ids you want to track for client side metrics. Separate values with a comma.
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Memo(Settings)>
|
||||
</ClientSideUserIdsSetting>
|
||||
`;
|
||||
|
||||
exports[`components/AdminConsole/ClientSideUserIdsSetting renders properly when disabled 1`] = `
|
||||
<ClientSideUserIdsSetting
|
||||
disabled={true}
|
||||
id="MySetting"
|
||||
onChange={[MockFunction]}
|
||||
setByEnv={false}
|
||||
value={
|
||||
Array [
|
||||
"userid1",
|
||||
"userid2",
|
||||
]
|
||||
}
|
||||
>
|
||||
<Memo(Settings)
|
||||
helpText={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Set the user ids you want to track for client side metrics. Separate values with a comma."
|
||||
id="admin.customization.clientSideUserIdsDesc"
|
||||
/>
|
||||
}
|
||||
inputId="MySetting"
|
||||
label={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Client side user ids:"
|
||||
id="admin.customization.clientSideUserIds"
|
||||
/>
|
||||
}
|
||||
setByEnv={false}
|
||||
>
|
||||
<div
|
||||
className="form-group"
|
||||
data-testid="MySetting"
|
||||
>
|
||||
<label
|
||||
className="control-label col-sm-4"
|
||||
htmlFor="MySetting"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Client side user ids:"
|
||||
id="admin.customization.clientSideUserIds"
|
||||
>
|
||||
<span>
|
||||
Client side user ids:
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</label>
|
||||
<div
|
||||
className="col-sm-8"
|
||||
>
|
||||
<LocalizedPlaceholderInput
|
||||
className="form-control"
|
||||
disabled={true}
|
||||
id="MySetting"
|
||||
onChange={[Function]}
|
||||
placeholder={
|
||||
Object {
|
||||
"defaultMessage": "E.g.: \\"userid1,userid2\\"",
|
||||
"id": "admin.customization.clientSideUserIdsPlaceholder",
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
value="userid1,userid2"
|
||||
>
|
||||
<input
|
||||
className="form-control"
|
||||
disabled={true}
|
||||
id="MySetting"
|
||||
onChange={[Function]}
|
||||
placeholder="E.g.: \\"userid1,userid2\\""
|
||||
type="text"
|
||||
value="userid1,userid2"
|
||||
/>
|
||||
</LocalizedPlaceholderInput>
|
||||
<div
|
||||
className="help-text"
|
||||
data-testid="MySettinghelp-text"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Set the user ids you want to track for client side metrics. Separate values with a comma."
|
||||
id="admin.customization.clientSideUserIdsDesc"
|
||||
>
|
||||
<span>
|
||||
Set the user ids you want to track for client side metrics. Separate values with a comma.
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Memo(Settings)>
|
||||
</ClientSideUserIdsSetting>
|
||||
`;
|
||||
|
||||
exports[`components/AdminConsole/ClientSideUserIdsSetting renders properly when set by environment variable 1`] = `
|
||||
<ClientSideUserIdsSetting
|
||||
disabled={false}
|
||||
id="MySetting"
|
||||
onChange={[MockFunction]}
|
||||
setByEnv={true}
|
||||
value={
|
||||
Array [
|
||||
"userid1",
|
||||
"userid2",
|
||||
]
|
||||
}
|
||||
>
|
||||
<Memo(Settings)
|
||||
helpText={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Set the user ids you want to track for client side metrics. Separate values with a comma."
|
||||
id="admin.customization.clientSideUserIdsDesc"
|
||||
/>
|
||||
}
|
||||
inputId="MySetting"
|
||||
label={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Client side user ids:"
|
||||
id="admin.customization.clientSideUserIds"
|
||||
/>
|
||||
}
|
||||
setByEnv={true}
|
||||
>
|
||||
<div
|
||||
className="form-group"
|
||||
data-testid="MySetting"
|
||||
>
|
||||
<label
|
||||
className="control-label col-sm-4"
|
||||
htmlFor="MySetting"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Client side user ids:"
|
||||
id="admin.customization.clientSideUserIds"
|
||||
>
|
||||
<span>
|
||||
Client side user ids:
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</label>
|
||||
<div
|
||||
className="col-sm-8"
|
||||
>
|
||||
<LocalizedPlaceholderInput
|
||||
className="form-control"
|
||||
disabled={true}
|
||||
id="MySetting"
|
||||
onChange={[Function]}
|
||||
placeholder={
|
||||
Object {
|
||||
"defaultMessage": "E.g.: \\"userid1,userid2\\"",
|
||||
"id": "admin.customization.clientSideUserIdsPlaceholder",
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
value="userid1,userid2"
|
||||
>
|
||||
<input
|
||||
className="form-control"
|
||||
disabled={true}
|
||||
id="MySetting"
|
||||
onChange={[Function]}
|
||||
placeholder="E.g.: \\"userid1,userid2\\""
|
||||
type="text"
|
||||
value="userid1,userid2"
|
||||
/>
|
||||
</LocalizedPlaceholderInput>
|
||||
<div
|
||||
className="help-text"
|
||||
data-testid="MySettinghelp-text"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Set the user ids you want to track for client side metrics. Separate values with a comma."
|
||||
id="admin.customization.clientSideUserIdsDesc"
|
||||
>
|
||||
<span>
|
||||
Set the user ids you want to track for client side metrics. Separate values with a comma.
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
<SetByEnv>
|
||||
<div
|
||||
className="alert alert-warning"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="This setting has been set through an environment variable. It cannot be changed through the System Console."
|
||||
id="admin.set_by_env"
|
||||
>
|
||||
<span>
|
||||
This setting has been set through an environment variable. It cannot be changed through the System Console.
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
</SetByEnv>
|
||||
</div>
|
||||
</div>
|
||||
</Memo(Settings)>
|
||||
</ClientSideUserIdsSetting>
|
||||
`;
|
@ -49,6 +49,7 @@ import CompanyInfo, {searchableStrings as billingCompanyInfoSearchableStrings} f
|
||||
import CompanyInfoEdit from './billing/company_info_edit';
|
||||
import BleveSettings, {searchableStrings as bleveSearchableStrings} from './bleve_settings';
|
||||
import BrandImageSetting from './brand_image_setting/brand_image_setting';
|
||||
import ClientSideUserIdsSetting from './client_side_userids_setting';
|
||||
import ClusterSettings, {searchableStrings as clusterSearchableStrings} from './cluster_settings';
|
||||
import CustomEnableDisableGuestAccountsSetting from './custom_enable_disable_guest_accounts_setting';
|
||||
import CustomTermsOfServiceSettings from './custom_terms_of_service_settings';
|
||||
@ -1963,6 +1964,15 @@ const AdminDefinition: AdminDefinitionType = {
|
||||
it.configIsFalse('MetricsSettings', 'Enable'),
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
key: 'MetricsSettings.ClientSideUserIds',
|
||||
component: ClientSideUserIdsSetting,
|
||||
isDisabled: it.any(
|
||||
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.PERFORMANCE_MONITORING)),
|
||||
it.configIsFalse('MetricsSettings', 'EnableClientMetrics'),
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
key: 'MetricsSettings.ListenAddress',
|
||||
|
@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
|
||||
import ClientSideUserIdsSetting from './client_side_userids_setting';
|
||||
|
||||
describe('components/AdminConsole/ClientSideUserIdsSetting', () => {
|
||||
const baseProps = {
|
||||
id: 'MySetting',
|
||||
value: ['userid1', 'userid2'],
|
||||
onChange: jest.fn(),
|
||||
disabled: false,
|
||||
setByEnv: false,
|
||||
};
|
||||
|
||||
describe('initial state', () => {
|
||||
test('with no items', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
value: [],
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ClientSideUserIdsSetting {...props}/>,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
expect(wrapper.state('value')).toEqual('');
|
||||
});
|
||||
|
||||
test('with one item', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
value: ['userid1'],
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ClientSideUserIdsSetting {...props}/>,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
expect(wrapper.state('value')).toEqual('userid1');
|
||||
});
|
||||
|
||||
test('with multiple items', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
value: ['userid1', 'userid2', 'id3'],
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ClientSideUserIdsSetting {...props}/>,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
expect(wrapper.state('value')).toEqual('userid1,userid2,id3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChange', () => {
|
||||
test('called on change to empty', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ClientSideUserIdsSetting {...props}/>,
|
||||
);
|
||||
|
||||
wrapper.find('input').simulate('change', {target: {value: ''}});
|
||||
|
||||
expect(props.onChange).toBeCalledWith(baseProps.id, []);
|
||||
});
|
||||
|
||||
test('called on change to one item', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ClientSideUserIdsSetting {...props}/>,
|
||||
);
|
||||
|
||||
wrapper.find('input').simulate('change', {target: {value: ' id2 '}});
|
||||
|
||||
expect(props.onChange).toBeCalledWith(baseProps.id, ['id2']);
|
||||
});
|
||||
|
||||
test('called on change to two items', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ClientSideUserIdsSetting {...props}/>,
|
||||
);
|
||||
|
||||
wrapper.find('input').simulate('change', {target: {value: 'id1, id99'}});
|
||||
|
||||
expect(props.onChange).toBeCalledWith(baseProps.id, ['id1', 'id99']);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders properly when disabled', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ClientSideUserIdsSetting {...props}/>,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('renders properly when set by environment variable', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
setByEnv: true,
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ClientSideUserIdsSetting {...props}/>,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import type {ChangeEvent} from 'react';
|
||||
import {defineMessage, FormattedMessage} from 'react-intl';
|
||||
|
||||
import LocalizedPlaceholderInput from 'components/localized_placeholder_input';
|
||||
|
||||
import Setting from './setting';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
value: string[];
|
||||
onChange: (id: string, valueAsArray: string[]) => void;
|
||||
disabled: boolean;
|
||||
setByEnv: boolean;
|
||||
}
|
||||
|
||||
type State = {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default class ClientSideUserIdsSetting extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: this.arrayToString(props.value),
|
||||
};
|
||||
}
|
||||
|
||||
stringToArray = (str: string): string[] => {
|
||||
return str.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
arrayToString = (arr: string[]): string => {
|
||||
return arr.join(',');
|
||||
};
|
||||
|
||||
handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const valueAsArray = this.stringToArray(e.target.value);
|
||||
|
||||
this.props.onChange(this.props.id, valueAsArray);
|
||||
|
||||
this.setState({
|
||||
value: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Setting
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='admin.customization.clientSideUserIds'
|
||||
defaultMessage='Client side user ids:'
|
||||
/>
|
||||
}
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id='admin.customization.clientSideUserIdsDesc'
|
||||
defaultMessage='Set the user ids you want to track for client side metrics. Separate values with a comma.'
|
||||
/>
|
||||
}
|
||||
inputId={this.props.id}
|
||||
setByEnv={this.props.setByEnv}
|
||||
>
|
||||
<LocalizedPlaceholderInput
|
||||
id={this.props.id}
|
||||
className='form-control'
|
||||
type='text'
|
||||
placeholder={defineMessage({id: 'admin.customization.clientSideUserIdsPlaceholder', defaultMessage: 'E.g.: "userid1,userid2"'})}
|
||||
value={this.state.value}
|
||||
onChange={this.handleChange}
|
||||
disabled={this.props.disabled || this.props.setByEnv}
|
||||
/>
|
||||
</Setting>
|
||||
);
|
||||
}
|
||||
}
|
@ -40,6 +40,10 @@ export default class FileUploadSetting extends React.PureComponent<Props, State>
|
||||
};
|
||||
}
|
||||
|
||||
handleChooseClick = () => {
|
||||
this.fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
handleChange = () => {
|
||||
const files = this.fileInputRef.current?.files;
|
||||
if (files && files.length > 0) {
|
||||
@ -92,6 +96,7 @@ export default class FileUploadSetting extends React.PureComponent<Props, State>
|
||||
type='button'
|
||||
className='btn btn-tertiary'
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.handleChooseClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.file_upload.chooseFile'
|
||||
|
@ -123,7 +123,9 @@ table.adminConsoleListTable {
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
|
||||
&:hover {
|
||||
|
||||
|
||||
&.clickable:hover {
|
||||
background-color: $sysCenterChannelColorWith8Alpha;
|
||||
cursor: pointer;
|
||||
|
||||
@ -207,6 +209,20 @@ table.adminConsoleListTable {
|
||||
tfoot {
|
||||
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
width: 24px;
|
||||
height: calc(100% - 1px);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adminConsoleListTabletOptionalFoot,
|
||||
|
@ -6,10 +6,14 @@ import {flexRender} from '@tanstack/react-table';
|
||||
import classNames from 'classnames';
|
||||
import React, {useMemo} from 'react';
|
||||
import type {AriaAttributes, MouseEvent, ReactNode} from 'react';
|
||||
import type {DropResult} from 'react-beautiful-dnd';
|
||||
import {DragDropContext, Draggable, Droppable} from 'react-beautiful-dnd';
|
||||
import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
|
||||
import ReactSelect, {components} from 'react-select';
|
||||
import type {IndicatorContainerProps, ValueType} from 'react-select';
|
||||
|
||||
import {DragVerticalIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
|
||||
|
||||
import {Pagination} from './pagination';
|
||||
@ -56,6 +60,7 @@ export type TableMeta = {
|
||||
loadingState?: LoadingStates;
|
||||
emptyDataMessage?: ReactNode;
|
||||
onRowClick?: (row: string) => void;
|
||||
onReorder?: (prev: number, next: number) => void;
|
||||
disablePrevPage?: boolean;
|
||||
disableNextPage?: boolean;
|
||||
disablePaginationControls?: boolean;
|
||||
@ -117,6 +122,14 @@ export function ListTable<TableType extends TableMandatoryTypes>(
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
const {source, destination} = result;
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
tableMeta.onReorder?.(source.index, destination.index);
|
||||
};
|
||||
|
||||
const colCount = props.table.getAllColumns().length;
|
||||
const rowCount = props.table.getRowModel().rows.length;
|
||||
|
||||
@ -164,6 +177,7 @@ export function ListTable<TableType extends TableMandatoryTypes>(
|
||||
})}
|
||||
disabled={header.column.getCanSort() && tableMeta.loadingState === LoadingStates.Loading}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
style={{width: header.column.getSize()}}
|
||||
>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
|
||||
@ -193,14 +207,31 @@ export function ListTable<TableType extends TableMandatoryTypes>(
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId='table-body'>
|
||||
{(provided, snap) => (
|
||||
<tbody
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{props.table.getRowModel().rows.map((row) => (
|
||||
<Draggable
|
||||
draggableId={row.original.id}
|
||||
key={row.original.id}
|
||||
index={row.index}
|
||||
isDragDisabled={!tableMeta.onReorder}
|
||||
>
|
||||
{(provided) => {
|
||||
return (
|
||||
<tr
|
||||
id={`${rowIdPrefix}${row.original.id}`}
|
||||
key={row.id}
|
||||
onClick={handleRowClick}
|
||||
className={classNames({clickable: Boolean(tableMeta.onRowClick) && !snap.isDraggingOver})}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
{row.getVisibleCells().map((cell, i) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
id={`${cellIdPrefix}${cell.id}`}
|
||||
@ -208,13 +239,28 @@ export function ListTable<TableType extends TableMandatoryTypes>(
|
||||
className={classNames(`${cell.column.id}`, {
|
||||
[PINNED_CLASS]: cell.column.getCanPin(),
|
||||
})}
|
||||
style={{width: cell.column.getSize()}}
|
||||
>
|
||||
{tableMeta.onReorder && i === 0 && (
|
||||
<span
|
||||
className='dragHandle'
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<DragVerticalIcon size={18}/>
|
||||
</span>
|
||||
)}
|
||||
{cell.getIsPlaceholder() ? null : flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
|
||||
))}
|
||||
|
||||
{provided.placeholder}
|
||||
|
||||
{/* State where it is initially loading the data */}
|
||||
{(tableMeta.loadingState === LoadingStates.Loading && rowCount === 0) && (
|
||||
<tr>
|
||||
@ -256,6 +302,9 @@ export function ListTable<TableType extends TableMandatoryTypes>(
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<tfoot>
|
||||
{props.table.getFooterGroups().map((footerGroup) => (
|
||||
<tr key={footerGroup.id}>
|
||||
|
@ -7,7 +7,7 @@ import React, {useEffect, useMemo, useState} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import styled, {css} from 'styled-components';
|
||||
|
||||
import {PlusIcon, TextBoxOutlineIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components';
|
||||
import {MenuVariantIcon, PlusIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components';
|
||||
import type {UserPropertyField} from '@mattermost/types/properties';
|
||||
import {collectionToArray} from '@mattermost/types/utilities';
|
||||
|
||||
@ -30,6 +30,7 @@ type Props = {
|
||||
type FieldActions = {
|
||||
updateField: (field: UserPropertyField) => void;
|
||||
deleteField: (id: string) => void;
|
||||
reorderField: (field: UserPropertyField, nextOrder: number) => void;
|
||||
}
|
||||
|
||||
export const useUserPropertiesTable = (): SectionHook => {
|
||||
@ -53,6 +54,7 @@ export const useUserPropertiesTable = (): SectionHook => {
|
||||
data={userPropertyFields}
|
||||
updateField={itemOps.update}
|
||||
deleteField={itemOps.delete}
|
||||
reorderField={itemOps.reorder}
|
||||
/>
|
||||
{nonDeletedCount < Constants.MAX_CUSTOM_ATTRIBUTES && (
|
||||
<LinkButton onClick={itemOps.create}>
|
||||
@ -78,13 +80,14 @@ export const useUserPropertiesTable = (): SectionHook => {
|
||||
};
|
||||
};
|
||||
|
||||
export function UserPropertiesTable({data: collection, updateField, deleteField}: Props & FieldActions) {
|
||||
export function UserPropertiesTable({data: collection, updateField, deleteField, reorderField}: Props & FieldActions) {
|
||||
const {formatMessage} = useIntl();
|
||||
const data = collectionToArray(collection);
|
||||
const col = createColumnHelper<UserPropertyField>();
|
||||
const columns = useMemo<Array<ColumnDef<UserPropertyField, any>>>(() => {
|
||||
return [
|
||||
col.accessor('name', {
|
||||
size: 180,
|
||||
header: () => {
|
||||
return (
|
||||
<ColHeaderLeft>
|
||||
@ -150,6 +153,7 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
||||
enableSorting: false,
|
||||
}),
|
||||
col.accessor('type', {
|
||||
size: 100,
|
||||
header: () => {
|
||||
return (
|
||||
<ColHeaderLeft>
|
||||
@ -166,7 +170,7 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
||||
if (type === 'text') {
|
||||
type = (
|
||||
<>
|
||||
<TextBoxOutlineIcon
|
||||
<MenuVariantIcon
|
||||
size={18}
|
||||
color={'rgba(var(--center-channel-color-rgb), 0.64)'}
|
||||
/>
|
||||
@ -187,8 +191,17 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
}),
|
||||
col.display({
|
||||
id: 'options',
|
||||
size: 300,
|
||||
header: () => <></>,
|
||||
cell: () => <></>,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
}),
|
||||
col.display({
|
||||
id: 'actions',
|
||||
size: 100,
|
||||
header: () => {
|
||||
return (
|
||||
<ColHeaderRight>
|
||||
@ -202,7 +215,6 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
||||
cell: ({row}) => (
|
||||
<Actions
|
||||
field={row.original}
|
||||
updateField={updateField}
|
||||
deleteField={deleteField}
|
||||
/>
|
||||
),
|
||||
@ -215,9 +227,6 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
initialState: {
|
||||
sorting: [],
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel<UserPropertyField>(),
|
||||
getSortedRowModel: getSortedRowModel<UserPropertyField>(),
|
||||
enableSortingRemoval: false,
|
||||
@ -226,6 +235,9 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
||||
meta: {
|
||||
tableId: 'userProperties',
|
||||
disablePaginationControls: true,
|
||||
onReorder: (prev: number, next: number) => {
|
||||
reorderField(collection.data[collection.order[prev]], next);
|
||||
},
|
||||
},
|
||||
manualPagination: true,
|
||||
});
|
||||
@ -283,7 +295,7 @@ const TableWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const Actions = ({field, deleteField}: {field: UserPropertyField} & FieldActions) => {
|
||||
const Actions = ({field, deleteField}: {field: UserPropertyField} & Pick<FieldActions, 'deleteField'>) => {
|
||||
const {promptDelete} = useUserPropertyFieldDelete();
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
import {act} from '@testing-library/react-hooks';
|
||||
|
||||
import type {UserPropertyField} from '@mattermost/types/properties';
|
||||
import type {UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties';
|
||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
@ -48,11 +48,11 @@ describe('useUserPropertyFields', () => {
|
||||
const deleteField = jest.spyOn(Client4, 'deleteCustomProfileAttributeField');
|
||||
const createField = jest.spyOn(Client4, 'createCustomProfileAttributeField');
|
||||
|
||||
const baseField = {type: 'text' as const, group_id: 'custom_profile_attributes' as const, create_at: 1736541716295, delete_at: 0, update_at: 0};
|
||||
const field0: UserPropertyField = {id: 'f0', name: 'test attribute 0', ...baseField};
|
||||
const field1: UserPropertyField = {id: 'f1', name: 'test attribute 1', ...baseField};
|
||||
const field2: UserPropertyField = {id: 'f2', name: 'test attribute 2', ...baseField};
|
||||
const field3: UserPropertyField = {id: 'f3', name: 'test attribute 3', ...baseField};
|
||||
const baseField = {type: 'text', group_id: 'custom_profile_attributes', create_at: 1736541716295, delete_at: 0, update_at: 0} as const;
|
||||
const field0: UserPropertyField = {...baseField, id: 'f0', name: 'test attribute 0'};
|
||||
const field1: UserPropertyField = {...baseField, id: 'f1', name: 'test attribute 1'};
|
||||
const field2: UserPropertyField = {...baseField, id: 'f2', name: 'test attribute 2'};
|
||||
const field3: UserPropertyField = {...baseField, id: 'f3', name: 'test attribute 3'};
|
||||
|
||||
getFields.mockResolvedValue([field0, field1, field2, field3]);
|
||||
|
||||
@ -134,6 +134,59 @@ describe('useUserPropertyFields', () => {
|
||||
expect(fields4.data[field1.id].name).toBe('changed attribute value');
|
||||
});
|
||||
|
||||
it('should successfully handle reordering', async () => {
|
||||
patchField.mockImplementation((id: string, patch: UserPropertyFieldPatch) => Promise.resolve({...baseField, ...patch, id, update_at: Date.now()} as UserPropertyField));
|
||||
|
||||
const {result, rerender, waitFor} = renderHookWithContext(() => {
|
||||
return useUserPropertyFields();
|
||||
}, getBaseState());
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
const [, read] = result.current;
|
||||
expect(read.loading).toBe(false);
|
||||
});
|
||||
const [fields2,,, ops2] = result.current;
|
||||
|
||||
act(() => {
|
||||
ops2.reorder(fields2.data[field1.id], 0);
|
||||
});
|
||||
rerender();
|
||||
|
||||
const [fields3, readIO3, pendingIO3] = result.current;
|
||||
|
||||
// expect 2 changed fields to be in the correct order
|
||||
expect(fields3.data[field1.id]?.attrs?.sort_order).toBe(0);
|
||||
expect(fields3.data[field0.id]?.attrs?.sort_order).toBe(1);
|
||||
expect(pendingIO3.hasChanges).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
const data = await pendingIO3.commit();
|
||||
if (data) {
|
||||
readIO3.setData(data);
|
||||
}
|
||||
jest.runAllTimers();
|
||||
rerender();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const [,, pending] = result.current;
|
||||
expect(pending.saving).toBe(false);
|
||||
});
|
||||
|
||||
expect(patchField).toHaveBeenCalledWith(field1.id, {type: 'text', name: 'test attribute 1', attrs: {sort_order: 0}});
|
||||
expect(patchField).toHaveBeenCalledWith(field0.id, {type: 'text', name: 'test attribute 0', attrs: {sort_order: 1}});
|
||||
|
||||
const [fields4,, pendingIO4] = result.current;
|
||||
expect(pendingIO4.hasChanges).toBe(false);
|
||||
expect(pendingIO4.error).toBe(undefined);
|
||||
expect(fields4.order).toEqual(['f1', 'f0', 'f2', 'f3']);
|
||||
});
|
||||
|
||||
it('should successfully handle deletes', async () => {
|
||||
const {result, rerender, waitFor} = renderHookWithContext(() => {
|
||||
return useUserPropertyFields();
|
||||
@ -224,8 +277,8 @@ describe('useUserPropertyFields', () => {
|
||||
expect(pendingIO4.saving).toBe(false);
|
||||
});
|
||||
|
||||
expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text'});
|
||||
expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text 2'});
|
||||
expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text', attrs: {sort_order: 4}});
|
||||
expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text 2', attrs: {sort_order: 5}});
|
||||
|
||||
const [fields4,,,] = result.current;
|
||||
expect(Object.values(fields4.data)).toEqual(expect.arrayContaining([
|
||||
|
@ -11,6 +11,7 @@ import {collectionAddItem, collectionFromArray, collectionRemoveItem, collection
|
||||
import type {PartialExcept, IDMappedCollection, IDMappedObjects} from '@mattermost/types/utilities';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {insertWithoutDuplicates} from 'mattermost-redux/utils/array_utils';
|
||||
|
||||
import {generateId} from 'utils/utils';
|
||||
|
||||
@ -26,7 +27,8 @@ export const useUserPropertyFields = () => {
|
||||
const [fieldCollection, readIO] = useThing<UserPropertyFields>(useMemo(() => ({
|
||||
get: async () => {
|
||||
const data = await Client4.getCustomProfileAttributeFields();
|
||||
return collectionFromArray(data);
|
||||
const sorted = data.sort((a, b) => (a.attrs?.sort_order ?? 0) - (b.attrs?.sort_order ?? 0));
|
||||
return collectionFromArray(sorted);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
select: (state) => {
|
||||
@ -65,7 +67,7 @@ export const useUserPropertyFields = () => {
|
||||
errors: {}, // start with errors cleared; don't keep stale errors
|
||||
};
|
||||
|
||||
// delete - all
|
||||
// delete
|
||||
await Promise.all(process.delete.map(async ({id}) => {
|
||||
return Client4.deleteCustomProfileAttributeField(id).
|
||||
then(() => {
|
||||
@ -80,11 +82,11 @@ export const useUserPropertyFields = () => {
|
||||
});
|
||||
}));
|
||||
|
||||
// update - all
|
||||
// update
|
||||
await Promise.all(process.edit.map(async (pendingItem) => {
|
||||
const {id, name, type} = pendingItem;
|
||||
const {id, name, type, attrs} = pendingItem;
|
||||
|
||||
return Client4.patchCustomProfileAttributeField(id, {name, type}).
|
||||
return Client4.patchCustomProfileAttributeField(id, {name, type, attrs}).
|
||||
then((nextItem) => {
|
||||
// data:updated
|
||||
next.data[id] = nextItem;
|
||||
@ -94,12 +96,11 @@ export const useUserPropertyFields = () => {
|
||||
});
|
||||
}));
|
||||
|
||||
// create - each, to preserve created/sort ordering
|
||||
for (const pendingItem of process.create) {
|
||||
const {id, name, type} = pendingItem;
|
||||
// create
|
||||
await Promise.all(process.create.map(async (pendingItem) => {
|
||||
const {id, name, type, attrs} = pendingItem;
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await Client4.createCustomProfileAttributeField({name, type}).
|
||||
return Client4.createCustomProfileAttributeField({name, type, attrs}).
|
||||
then((newItem) => {
|
||||
// data:created (delete pending data)
|
||||
Reflect.deleteProperty(next.data, id);
|
||||
@ -111,7 +112,7 @@ export const useUserPropertyFields = () => {
|
||||
catch((reason: ClientError) => {
|
||||
next.errors = {...next.errors, [id]: reason};
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
if (isEmpty(next.errors)) {
|
||||
Reflect.deleteProperty(next, 'errors');
|
||||
@ -175,11 +176,34 @@ export const useUserPropertyFields = () => {
|
||||
},
|
||||
create: () => {
|
||||
pendingIO.apply((pending) => {
|
||||
const nextOrder = Object.values(pending.data).filter((x) => !isDeletePending(x)).length;
|
||||
const name = getIncrementedName('Text', pending);
|
||||
const field = newPendingField({name, type: 'text'});
|
||||
const field = newPendingField({name, type: 'text', attrs: {sort_order: nextOrder}});
|
||||
return collectionAddItem(pending, field);
|
||||
});
|
||||
},
|
||||
reorder: ({id}, nextItemOrder) => {
|
||||
pendingIO.apply((pending) => {
|
||||
const nextOrder = insertWithoutDuplicates(pending.order, id, nextItemOrder);
|
||||
|
||||
if (nextOrder === pending.order) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const nextItems = Object.values(pending.data).reduce<UserPropertyField[]>((changedItems, item) => {
|
||||
const itemCurrentOrder = item.attrs?.sort_order;
|
||||
const itemNextOrder = nextOrder.indexOf(item.id);
|
||||
|
||||
if (itemNextOrder !== itemCurrentOrder) {
|
||||
changedItems.push({...item, attrs: {sort_order: itemNextOrder}});
|
||||
}
|
||||
|
||||
return changedItems;
|
||||
}, []);
|
||||
|
||||
return collectionReplaceItem({...pending, order: nextOrder}, ...nextItems);
|
||||
});
|
||||
},
|
||||
delete: (id: string) => {
|
||||
pendingIO.apply((pending) => {
|
||||
const field = pending.data[id];
|
||||
|
@ -201,6 +201,11 @@ Object {
|
||||
<fieldset
|
||||
class="mm-modal-generic-section-item__fieldset-radio"
|
||||
>
|
||||
<legend
|
||||
class="hidden-label"
|
||||
>
|
||||
Notify me about…
|
||||
</legend>
|
||||
<label
|
||||
class="mm-modal-generic-section-item__label-radio"
|
||||
>
|
||||
@ -208,7 +213,7 @@ Object {
|
||||
checked=""
|
||||
data-testid="desktopNotification-all"
|
||||
id="desktopNotification-all"
|
||||
name="desktopNotification-all"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
@ -221,7 +226,7 @@ Object {
|
||||
<input
|
||||
data-testid="desktopNotification-mention"
|
||||
id="desktopNotification-mention"
|
||||
name="desktopNotification-mention"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="mention"
|
||||
/>
|
||||
@ -233,7 +238,7 @@ Object {
|
||||
<input
|
||||
data-testid="desktopNotification-none"
|
||||
id="desktopNotification-none"
|
||||
name="desktopNotification-none"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="none"
|
||||
/>
|
||||
@ -637,6 +642,11 @@ Object {
|
||||
<fieldset
|
||||
class="mm-modal-generic-section-item__fieldset-radio"
|
||||
>
|
||||
<legend
|
||||
class="hidden-label"
|
||||
>
|
||||
Notify me about…
|
||||
</legend>
|
||||
<label
|
||||
class="mm-modal-generic-section-item__label-radio"
|
||||
>
|
||||
@ -644,7 +654,7 @@ Object {
|
||||
checked=""
|
||||
data-testid="desktopNotification-all"
|
||||
id="desktopNotification-all"
|
||||
name="desktopNotification-all"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
@ -657,7 +667,7 @@ Object {
|
||||
<input
|
||||
data-testid="desktopNotification-mention"
|
||||
id="desktopNotification-mention"
|
||||
name="desktopNotification-mention"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="mention"
|
||||
/>
|
||||
@ -669,7 +679,7 @@ Object {
|
||||
<input
|
||||
data-testid="desktopNotification-none"
|
||||
id="desktopNotification-none"
|
||||
name="desktopNotification-none"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="none"
|
||||
/>
|
||||
@ -836,13 +846,18 @@ Object {
|
||||
<fieldset
|
||||
class="mm-modal-generic-section-item__fieldset-radio"
|
||||
>
|
||||
<legend
|
||||
class="hidden-label"
|
||||
>
|
||||
Notify me about…
|
||||
</legend>
|
||||
<label
|
||||
class="mm-modal-generic-section-item__label-radio"
|
||||
>
|
||||
<input
|
||||
data-testid="MobileNotification-all"
|
||||
id="MobileNotification-all"
|
||||
name="MobileNotification-all"
|
||||
name="push"
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
@ -855,7 +870,7 @@ Object {
|
||||
checked=""
|
||||
data-testid="MobileNotification-mention"
|
||||
id="MobileNotification-mention"
|
||||
name="MobileNotification-mention"
|
||||
name="push"
|
||||
type="radio"
|
||||
value="mention"
|
||||
/>
|
||||
@ -868,7 +883,7 @@ Object {
|
||||
<input
|
||||
data-testid="MobileNotification-none"
|
||||
id="MobileNotification-none"
|
||||
name="MobileNotification-none"
|
||||
name="push"
|
||||
type="radio"
|
||||
value="none"
|
||||
/>
|
||||
@ -1530,6 +1545,11 @@ Object {
|
||||
<fieldset
|
||||
class="mm-modal-generic-section-item__fieldset-radio"
|
||||
>
|
||||
<legend
|
||||
class="hidden-label"
|
||||
>
|
||||
Notify me about…
|
||||
</legend>
|
||||
<label
|
||||
class="mm-modal-generic-section-item__label-radio"
|
||||
>
|
||||
@ -1537,7 +1557,7 @@ Object {
|
||||
checked=""
|
||||
data-testid="desktopNotification-all"
|
||||
id="desktopNotification-all"
|
||||
name="desktopNotification-all"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
@ -1550,7 +1570,7 @@ Object {
|
||||
<input
|
||||
data-testid="desktopNotification-mention"
|
||||
id="desktopNotification-mention"
|
||||
name="desktopNotification-mention"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="mention"
|
||||
/>
|
||||
@ -1562,7 +1582,7 @@ Object {
|
||||
<input
|
||||
data-testid="desktopNotification-none"
|
||||
id="desktopNotification-none"
|
||||
name="desktopNotification-none"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="none"
|
||||
/>
|
||||
@ -2047,6 +2067,11 @@ Object {
|
||||
<fieldset
|
||||
class="mm-modal-generic-section-item__fieldset-radio"
|
||||
>
|
||||
<legend
|
||||
class="hidden-label"
|
||||
>
|
||||
Notify me about…
|
||||
</legend>
|
||||
<label
|
||||
class="mm-modal-generic-section-item__label-radio"
|
||||
>
|
||||
@ -2054,7 +2079,7 @@ Object {
|
||||
checked=""
|
||||
data-testid="desktopNotification-all"
|
||||
id="desktopNotification-all"
|
||||
name="desktopNotification-all"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
@ -2067,7 +2092,7 @@ Object {
|
||||
<input
|
||||
data-testid="desktopNotification-mention"
|
||||
id="desktopNotification-mention"
|
||||
name="desktopNotification-mention"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="mention"
|
||||
/>
|
||||
@ -2079,7 +2104,7 @@ Object {
|
||||
<input
|
||||
data-testid="desktopNotification-none"
|
||||
id="desktopNotification-none"
|
||||
name="desktopNotification-none"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="none"
|
||||
/>
|
||||
@ -2564,6 +2589,11 @@ Object {
|
||||
<fieldset
|
||||
class="mm-modal-generic-section-item__fieldset-radio"
|
||||
>
|
||||
<legend
|
||||
class="hidden-label"
|
||||
>
|
||||
Notify me about…
|
||||
</legend>
|
||||
<label
|
||||
class="mm-modal-generic-section-item__label-radio"
|
||||
>
|
||||
@ -2571,7 +2601,7 @@ Object {
|
||||
checked=""
|
||||
data-testid="desktopNotification-all"
|
||||
id="desktopNotification-all"
|
||||
name="desktopNotification-all"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="all"
|
||||
/>
|
||||
@ -2584,7 +2614,7 @@ Object {
|
||||
<input
|
||||
data-testid="desktopNotification-mention"
|
||||
id="desktopNotification-mention"
|
||||
name="desktopNotification-mention"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="mention"
|
||||
/>
|
||||
@ -2596,7 +2626,7 @@ Object {
|
||||
<input
|
||||
data-testid="desktopNotification-none"
|
||||
id="desktopNotification-none"
|
||||
name="desktopNotification-none"
|
||||
name="desktop"
|
||||
type="radio"
|
||||
value="none"
|
||||
/>
|
||||
|
@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {defineMessage, FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {FieldsetCheckbox} from 'components/widgets/modals/components/checkbox_setting_item';
|
||||
import type {FieldsetRadio} from 'components/widgets/modals/components/radio_setting_item';
|
||||
@ -41,8 +41,12 @@ export const AutoFollowThreadsInputFieldData: FieldsetCheckbox = {
|
||||
dataTestId: 'autoFollowThreads',
|
||||
};
|
||||
|
||||
export const desktopNotificationInputFieldData = (defaultOption: string): FieldsetRadio => {
|
||||
return {
|
||||
const defaultMessage = defineMessage({
|
||||
id: 'channel_notifications.default',
|
||||
defaultMessage: '(default)',
|
||||
});
|
||||
|
||||
export const desktopNotificationInputFieldData = (defaultOption: string): FieldsetRadio => ({
|
||||
options: [
|
||||
{
|
||||
dataTestId: `desktopNotification-${NotificationLevels.ALL}`,
|
||||
@ -53,13 +57,12 @@ export const desktopNotificationInputFieldData = (defaultOption: string): Fields
|
||||
values={{
|
||||
optionalDefault: defaultOption === NotificationLevels.ALL ? (
|
||||
<FormattedMessage
|
||||
id='channel_notifications.default'
|
||||
defaultMessage='(default)'
|
||||
{...defaultMessage}
|
||||
/>) : undefined,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
name: `desktopNotification-${NotificationLevels.ALL}`,
|
||||
name: 'desktop',
|
||||
key: `desktopNotification-${NotificationLevels.ALL}`,
|
||||
value: NotificationLevels.ALL,
|
||||
},
|
||||
@ -72,13 +75,12 @@ export const desktopNotificationInputFieldData = (defaultOption: string): Fields
|
||||
values={{
|
||||
optionalDefault: defaultOption === NotificationLevels.MENTION ? (
|
||||
<FormattedMessage
|
||||
id='channel_notifications.default'
|
||||
defaultMessage='(default)'
|
||||
{...defaultMessage}
|
||||
/>) : undefined,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
name: `desktopNotification-${NotificationLevels.MENTION}`,
|
||||
name: 'desktop',
|
||||
key: `desktopNotification-${NotificationLevels.MENTION}`,
|
||||
value: NotificationLevels.MENTION,
|
||||
},
|
||||
@ -91,19 +93,18 @@ export const desktopNotificationInputFieldData = (defaultOption: string): Fields
|
||||
values={{
|
||||
optionalDefault: defaultOption === NotificationLevels.NONE ? (
|
||||
<FormattedMessage
|
||||
id='channel_notifications.default'
|
||||
defaultMessage='(default)'
|
||||
{...defaultMessage}
|
||||
/>) : undefined,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
name: `desktopNotification-${NotificationLevels.NONE}`,
|
||||
name: 'desktop',
|
||||
key: `desktopNotification-${NotificationLevels.NONE}`,
|
||||
value: NotificationLevels.NONE,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const desktopNotificationSoundsCheckboxFieldData: FieldsetCheckbox = {
|
||||
name: 'desktopNotificationSoundsCheckbox',
|
||||
@ -116,8 +117,7 @@ export const desktopNotificationSoundsSelectFieldData: FieldsetReactSelect = {
|
||||
options: optionsOfMessageNotificationSoundsSelect,
|
||||
};
|
||||
|
||||
export const mobileNotificationInputFieldData = (defaultOption: string): FieldsetRadio => {
|
||||
return {
|
||||
export const mobileNotificationInputFieldData = (defaultOption: string): FieldsetRadio => ({
|
||||
options: [
|
||||
{
|
||||
dataTestId: `MobileNotification-${NotificationLevels.ALL}`,
|
||||
@ -128,13 +128,12 @@ export const mobileNotificationInputFieldData = (defaultOption: string): Fieldse
|
||||
values={{
|
||||
optionalDefault: defaultOption === NotificationLevels.ALL ? (
|
||||
<FormattedMessage
|
||||
id='channel_notifications.default'
|
||||
defaultMessage='(default)'
|
||||
{...defaultMessage}
|
||||
/>) : undefined,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
name: `MobileNotification-${NotificationLevels.ALL}`,
|
||||
name: 'push',
|
||||
key: `MobileNotification-${NotificationLevels.ALL}`,
|
||||
value: NotificationLevels.ALL,
|
||||
},
|
||||
@ -142,18 +141,18 @@ export const mobileNotificationInputFieldData = (defaultOption: string): Fieldse
|
||||
dataTestId: `MobileNotification-${NotificationLevels.MENTION}`,
|
||||
title: (
|
||||
<FormattedMessage
|
||||
|
||||
id='channelNotifications.mobileNotification.mention'
|
||||
defaultMessage='Mentions, direct messages, and keywords only {optionalDefault}'
|
||||
values={{
|
||||
optionalDefault: defaultOption === NotificationLevels.MENTION ? (
|
||||
<FormattedMessage
|
||||
id='channel_notifications.default'
|
||||
defaultMessage='(default)'
|
||||
{...defaultMessage}
|
||||
/>) : undefined,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
name: `MobileNotification-${NotificationLevels.MENTION}`,
|
||||
name: 'push',
|
||||
key: `MobileNotification-${NotificationLevels.MENTION}`,
|
||||
value: NotificationLevels.MENTION,
|
||||
},
|
||||
@ -166,19 +165,18 @@ export const mobileNotificationInputFieldData = (defaultOption: string): Fieldse
|
||||
values={{
|
||||
optionalDefault: defaultOption === NotificationLevels.NONE ? (
|
||||
<FormattedMessage
|
||||
id='channel_notifications.default'
|
||||
defaultMessage='(default)'
|
||||
{...defaultMessage}
|
||||
/>) : undefined,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
name: `MobileNotification-${NotificationLevels.NONE}`,
|
||||
name: 'push',
|
||||
key: `MobileNotification-${NotificationLevels.NONE}`,
|
||||
value: NotificationLevels.NONE,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default {
|
||||
desktopNotificationInputFieldData,
|
||||
|
@ -159,13 +159,16 @@ function PostReminderSubmenu(props: Props) {
|
||||
leadingElement={<ClockOutlineIcon size={18}/>}
|
||||
trailingElements={<span className={'dot-menu__item-trailing-icon'}><ChevronRightIcon size={16}/></span>}
|
||||
menuId={`remind_post_${props.post.id}-menu`}
|
||||
>
|
||||
subMenuHeader={
|
||||
<h5 className={'dot-menu__post-reminder-menu-header'}>
|
||||
{formatMessage(
|
||||
{id: 'post_info.post_reminder.sub_menu.header',
|
||||
defaultMessage: 'Set a reminder for:'},
|
||||
{
|
||||
id: 'post_info.post_reminder.sub_menu.header',
|
||||
defaultMessage: 'Set a reminder for:',
|
||||
},
|
||||
)}
|
||||
</h5>
|
||||
</h5>}
|
||||
>
|
||||
{postReminderSubMenuItems}
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
|
@ -14,6 +14,7 @@
|
||||
|
||||
&__main {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
@ -68,6 +69,10 @@
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.DraftList.Drafts__main {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -7,12 +7,13 @@
|
||||
min-width: 114px;
|
||||
max-width: 496px;
|
||||
max-height: 80vh;
|
||||
padding: 4px 0;
|
||||
background-color: var(--center-channel-bg);
|
||||
box-shadow: var(--elevation-4);
|
||||
box-shadow: var(--elevation-4), 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.12) inset;
|
||||
}
|
||||
&.AsSubMenu {
|
||||
& .MuiPaper-root {
|
||||
box-shadow: var(--elevation-5);
|
||||
box-shadow: var(--elevation-5), 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.12) inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -140,7 +140,7 @@ describe('menu click handlers', () => {
|
||||
expect(screen.getByText('Open modal from submenu')).toBeInTheDocument();
|
||||
|
||||
// Press the down arrow once to focus first submenu item and then twice more to select the one we want
|
||||
userEvent.keyboard('{arrowdown}{arrowdown}{arrowdown}');
|
||||
userEvent.keyboard('{arrowdown}{arrowdown}');
|
||||
|
||||
expect(screen.getByText('Open modal from submenu').closest('li')).toHaveFocus();
|
||||
|
||||
|
@ -309,6 +309,7 @@ export const MenuItemStyled = styled(MuiMenuItem, {
|
||||
flexWrap: 'nowrap',
|
||||
justifyContent: 'flex-end',
|
||||
color: isRegular ? 'rgba(var(--center-channel-color-rgb), 0.75)' : 'var(--error-text)',
|
||||
marginInlineStart: '24px',
|
||||
gap: '4px',
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
|
@ -1,8 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import MuiMenu from '@mui/material/Menu';
|
||||
import MuiMenuList from '@mui/material/MenuList';
|
||||
import MuiPopover from '@mui/material/Popover';
|
||||
import type {PopoverOrigin} from '@mui/material/Popover';
|
||||
import React, {
|
||||
useState,
|
||||
@ -33,6 +33,8 @@ import {SubMenuContext, useMenuContextValue} from './menu_context';
|
||||
import {MenuItem} from './menu_item';
|
||||
import type {Props as MenuItemProps} from './menu_item';
|
||||
|
||||
import './menu.scss';
|
||||
|
||||
interface Props {
|
||||
id: MenuItemProps['id'];
|
||||
leadingElement?: MenuItemProps['leadingElement'];
|
||||
@ -47,6 +49,7 @@ interface Props {
|
||||
menuAriaDescribedBy?: string;
|
||||
forceOpenOnLeft?: boolean; // Most of the times this is not needed, since submenu position is calculated and placed
|
||||
|
||||
subMenuHeader?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@ -63,6 +66,7 @@ export function SubMenu(props: Props) {
|
||||
menuAriaDescribedBy,
|
||||
forceOpenOnLeft,
|
||||
children,
|
||||
subMenuHeader,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@ -136,6 +140,7 @@ export function SubMenu(props: Props) {
|
||||
menuId,
|
||||
menuAriaLabel,
|
||||
children,
|
||||
subMenuHeader,
|
||||
},
|
||||
}));
|
||||
}
|
||||
@ -166,35 +171,30 @@ export function SubMenu(props: Props) {
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<MuiMenu
|
||||
<SubMenuContext.Provider value={providerValue}>
|
||||
<MuiPopover
|
||||
anchorEl={anchorElement}
|
||||
open={isSubMenuOpen}
|
||||
anchorOrigin={originOfAnchorAndTransform.anchorOrigin}
|
||||
transformOrigin={originOfAnchorAndTransform.transformOrigin}
|
||||
sx={{pointerEvents: 'none'}}
|
||||
className='menu_menuStyled AsSubMenu'
|
||||
>
|
||||
{/* This component is needed here to re enable pointer events for the submenu items which we had to disable above as */}
|
||||
{/* pointer turns to default as soon as it leaves the parent menu */}
|
||||
{/* Notice we dont use the below component in menu.tsx */}
|
||||
{subMenuHeader}
|
||||
<MuiMenuList
|
||||
id={menuId}
|
||||
component='ul'
|
||||
aria-label={menuAriaLabel}
|
||||
aria-describedby={menuAriaDescribedBy}
|
||||
className={A11yClassNames.POPUP}
|
||||
onKeyDown={handleSubMenuKeyDown}
|
||||
autoFocusItem={isSubMenuOpen}
|
||||
sx={{
|
||||
pointerEvents: 'auto', // reset pointer events to default from here on
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
py: 0,
|
||||
}}
|
||||
>
|
||||
<SubMenuContext.Provider value={providerValue}>
|
||||
{children}
|
||||
</SubMenuContext.Provider>
|
||||
</MuiMenuList>
|
||||
</MuiMenu>
|
||||
</MuiPopover>
|
||||
</SubMenuContext.Provider>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
@ -203,6 +203,7 @@ interface SubMenuModalProps {
|
||||
menuId: Props['menuId'];
|
||||
menuAriaLabel?: Props['menuAriaLabel'];
|
||||
children: Props['children'];
|
||||
subMenuHeader?: ReactNode;
|
||||
}
|
||||
|
||||
function SubMenuModal(props: SubMenuModalProps) {
|
||||
@ -224,9 +225,11 @@ function SubMenuModal(props: SubMenuModalProps) {
|
||||
className='menuModal'
|
||||
>
|
||||
<MuiMenuList
|
||||
component={'div'}
|
||||
aria-hidden={true}
|
||||
onClick={handleModalClose}
|
||||
>
|
||||
{props.subMenuHeader}
|
||||
{props.children}
|
||||
</MuiMenuList>
|
||||
</GenericModal>
|
||||
|
@ -58,7 +58,7 @@ const AllowedDomainsSelect = ({allowedDomains, setAllowedDomains, setHasChanges,
|
||||
defaultMessage: 'When enabled, users can only join the team if their email matches a specific domain (e.g. "mattermost.org")',
|
||||
})}
|
||||
descriptionAboveContent={true}
|
||||
inputFieldData={{name: 'name'}}
|
||||
inputFieldData={{name: 'showAllowedDomains'}}
|
||||
inputFieldValue={showAllowedDomains}
|
||||
handleChange={handleEnableAllowedDomains}
|
||||
/>
|
||||
|
@ -57,10 +57,10 @@ const OpenInvite = ({isGroupConstrained, allowOpenInvite, setAllowOpenInvite}: P
|
||||
inputFieldTitle={
|
||||
<FormattedMessage
|
||||
id='general_tab.openInviteTitle'
|
||||
defaultMessage='Allow only users with a specific email domain to join this team'
|
||||
defaultMessage='Allow any user with an account on this server to join this team'
|
||||
/>
|
||||
}
|
||||
inputFieldData={{name: 'name'}}
|
||||
inputFieldData={{name: 'allowOpenInvite'}}
|
||||
inputFieldValue={allowOpenInvite}
|
||||
handleChange={setAllowOpenInvite}
|
||||
title={formatMessage({
|
||||
|
@ -120,4 +120,31 @@ describe('components/TeamSettings', () => {
|
||||
id: defaultProps.team?.id,
|
||||
});
|
||||
});
|
||||
|
||||
test('MM-62891 should toggle the right checkboxes when their labels are clicked on', () => {
|
||||
renderWithContext(<AccessTab {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByRole('checkbox', {name: 'Allow only users with a specific email domain to join this team'})).not.toBeChecked();
|
||||
expect(screen.getByRole('checkbox', {name: 'Allow any user with an account on this server to join this team'})).not.toBeChecked();
|
||||
|
||||
userEvent.click(screen.getByText('Allow only users with a specific email domain to join this team'));
|
||||
|
||||
expect(screen.getByRole('checkbox', {name: 'Allow only users with a specific email domain to join this team'})).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', {name: 'Allow any user with an account on this server to join this team'})).not.toBeChecked();
|
||||
|
||||
userEvent.click(screen.getByText('Allow only users with a specific email domain to join this team'));
|
||||
|
||||
expect(screen.getByRole('checkbox', {name: 'Allow only users with a specific email domain to join this team'})).not.toBeChecked();
|
||||
expect(screen.getByRole('checkbox', {name: 'Allow any user with an account on this server to join this team'})).not.toBeChecked();
|
||||
|
||||
userEvent.click(screen.getByText('Allow any user with an account on this server to join this team'));
|
||||
|
||||
expect(screen.getByRole('checkbox', {name: 'Allow only users with a specific email domain to join this team'})).not.toBeChecked();
|
||||
expect(screen.getByRole('checkbox', {name: 'Allow any user with an account on this server to join this team'})).toBeChecked();
|
||||
|
||||
userEvent.click(screen.getByText('Allow any user with an account on this server to join this team'));
|
||||
|
||||
expect(screen.getByRole('checkbox', {name: 'Allow only users with a specific email domain to join this team'})).not.toBeChecked();
|
||||
expect(screen.getByRole('checkbox', {name: 'Allow any user with an account on this server to join this team'})).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
@ -31,6 +31,7 @@ function mapStateToProps(state: GlobalState, {location: {pathname}}: Props) {
|
||||
unreadStatus: getUnreadStatus(state),
|
||||
inGlobalThreads: matchPath(pathname, {path: '/:team/threads/:threadIdentifier?'}) != null,
|
||||
inDrafts: matchPath(pathname, {path: '/:team/drafts'}) != null,
|
||||
inScheduledPosts: matchPath(pathname, {path: '/:team/scheduled_posts'}) != null,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -47,6 +47,7 @@ describe('components/UnreadsStatusHandler', () => {
|
||||
currentTeammate: null,
|
||||
inGlobalThreads: false,
|
||||
inDrafts: false,
|
||||
inScheduledPosts: false,
|
||||
};
|
||||
|
||||
test('set correctly the title when needed', () => {
|
||||
@ -83,6 +84,38 @@ describe('components/UnreadsStatusHandler', () => {
|
||||
currentTeammate: {} as Props['currentTeammate']});
|
||||
instance.updateTitle();
|
||||
expect(document.title).toBe('Mattermost - Join a team');
|
||||
|
||||
wrapper.setProps({
|
||||
inDrafts: false,
|
||||
inScheduledPosts: true,
|
||||
unreadStatus: 0,
|
||||
});
|
||||
instance.updateTitle();
|
||||
expect(document.title).toBe('Scheduled - Test team display name');
|
||||
|
||||
wrapper.setProps({
|
||||
inDrafts: false,
|
||||
inScheduledPosts: true,
|
||||
unreadStatus: 10,
|
||||
});
|
||||
instance.updateTitle();
|
||||
expect(document.title).toBe('(10) Scheduled - Test team display name');
|
||||
|
||||
wrapper.setProps({
|
||||
inDrafts: true,
|
||||
inScheduledPosts: false,
|
||||
unreadStatus: 0,
|
||||
});
|
||||
instance.updateTitle();
|
||||
expect(document.title).toBe('Drafts - Test team display name');
|
||||
|
||||
wrapper.setProps({
|
||||
inDrafts: true,
|
||||
inScheduledPosts: false,
|
||||
unreadStatus: 10,
|
||||
});
|
||||
instance.updateTitle();
|
||||
expect(document.title).toBe('(10) Drafts - Test team display name');
|
||||
});
|
||||
|
||||
test('should set correct title on mentions on safari', () => {
|
||||
@ -149,4 +182,18 @@ describe('components/UnreadsStatusHandler', () => {
|
||||
|
||||
expect(document.title).toBe('Drafts - Test team display name');
|
||||
});
|
||||
|
||||
test('should display correct title when in scheduled posts tab', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<UnreadsStatusHandler
|
||||
{...defaultProps}
|
||||
inScheduledPosts={true}
|
||||
currentChannel={undefined}
|
||||
siteName={undefined}
|
||||
/>,
|
||||
) as unknown as ShallowWrapper<Props, any, UnreadsStatusHandlerClass>;
|
||||
wrapper.instance().updateTitle();
|
||||
|
||||
expect(document.title).toBe('Scheduled - Test team display name');
|
||||
});
|
||||
});
|
||||
|
@ -46,6 +46,7 @@ type Props = {
|
||||
currentTeammate: Channel | null;
|
||||
inGlobalThreads: boolean;
|
||||
inDrafts: boolean;
|
||||
inScheduledPosts: boolean;
|
||||
};
|
||||
|
||||
export class UnreadsStatusHandlerClass extends React.PureComponent<Props> {
|
||||
@ -90,6 +91,7 @@ export class UnreadsStatusHandlerClass extends React.PureComponent<Props> {
|
||||
unreadStatus,
|
||||
inGlobalThreads,
|
||||
inDrafts,
|
||||
inScheduledPosts,
|
||||
} = this.props;
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
@ -126,6 +128,15 @@ export class UnreadsStatusHandlerClass extends React.PureComponent<Props> {
|
||||
displayName: currentTeam.display_name,
|
||||
siteName: currentSiteName,
|
||||
});
|
||||
} else if (currentTeam && inScheduledPosts) {
|
||||
document.title = formatMessage({
|
||||
id: 'scheduledPosts.title',
|
||||
defaultMessage: '{prefix}Scheduled - {displayName} {siteName}',
|
||||
}, {
|
||||
prefix: `${mentionTitle}${unreadTitle}`,
|
||||
displayName: currentTeam.display_name,
|
||||
siteName: currentSiteName,
|
||||
});
|
||||
} else {
|
||||
document.title = formatMessage({id: 'sidebar.team_select', defaultMessage: '{siteName} - Join a team'}, {siteName: currentSiteName || 'Mattermost'});
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ exports[`UserAccountNameMenuItem should not break if no props are passed 1`] = `
|
||||
<div>
|
||||
<li
|
||||
aria-haspopup="true"
|
||||
class="MuiButtonBase-root-JvZdr dKFJFs MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXqYNm kIRdVO MuiMenuItem-root MuiMenuItem-gutters sc-gswNZR cUFZeQ userAccountMenu_nameMenuItem"
|
||||
class="MuiButtonBase-root-JvZdr dKFJFs MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXqYNm kIRdVO MuiMenuItem-root MuiMenuItem-gutters sc-gswNZR jjvBbU userAccountMenu_nameMenuItem"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
|
@ -196,10 +196,9 @@ export default function UserAccountDndMenuItem(props: Props) {
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
role='menuitemradio' // Prevents menu item from closing, not a recommended solution
|
||||
aria-checked={props.isStatusDnd}
|
||||
trailingElements={trailingElement}
|
||||
>
|
||||
subMenuHeader={
|
||||
<h5
|
||||
id='userAccountMenu_dndSubMenuTitle'
|
||||
className='userAccountMenu_dndMenuItem_subMenuTitle'
|
||||
@ -210,6 +209,8 @@ export default function UserAccountDndMenuItem(props: Props) {
|
||||
defaultMessage: 'Clear after:',
|
||||
})}
|
||||
</h5>
|
||||
}
|
||||
>
|
||||
<Menu.Item
|
||||
id={DND_SUB_MENU_ITEMS_IDS.DO_NOT_CLEAR}
|
||||
labels={
|
||||
|
@ -54,7 +54,12 @@ function RadioSettingItem({
|
||||
});
|
||||
|
||||
const content = (
|
||||
<fieldset className='mm-modal-generic-section-item__fieldset-radio'>
|
||||
<fieldset
|
||||
className='mm-modal-generic-section-item__fieldset-radio'
|
||||
>
|
||||
<legend className='hidden-label'>
|
||||
{title}
|
||||
</legend>
|
||||
{[...fields]}
|
||||
</fieldset>
|
||||
);
|
||||
|
@ -2251,13 +2251,10 @@
|
||||
"analytics.system.privateGroups": "Прыватныя каналы",
|
||||
"analytics.system.publicChannels": "Публічныя каналы",
|
||||
"analytics.system.skippedIntensiveQueries": "Для забеспячэння максімальнай прадукцыйнасці некаторыя статыстычныя даныя адключаны. Вы можаце [паўторна ўключыць іх у config.json](!https://docs.mattermost.com/administration/statistics.html).",
|
||||
"analytics.system.textPosts": "Толькі тэкставыя паведамленні",
|
||||
"analytics.system.title": "Статыстыка сістэмы",
|
||||
"analytics.system.totalBotPosts": "Усяго паведамленняў ад ботаў",
|
||||
"analytics.system.totalChannels": "Усяго каналаў",
|
||||
"analytics.system.totalCommands": "Усяго каманд",
|
||||
"analytics.system.totalFilePosts": "Паведамленні з файламі",
|
||||
"analytics.system.totalHashtagPosts": "Паведамленні з хэштэгамі",
|
||||
"analytics.system.totalIncomingWebhooks": "Уваходныя вэбхукі",
|
||||
"analytics.system.totalMasterDbConnections": "Падлучэнняў да галоўнай БД",
|
||||
"analytics.system.totalOutgoingWebhooks": "Выходныя вэбхукі",
|
||||
|
@ -2833,13 +2833,10 @@
|
||||
"analytics.system.privateGroups": "Частни канали",
|
||||
"analytics.system.publicChannels": "Публични канали",
|
||||
"analytics.system.skippedIntensiveQueries": "За да се увеличи ефективността, някои статистически данни са деактивирани. Можете да <link>ги активирате отново в config.json</link>.",
|
||||
"analytics.system.textPosts": "Публикации само с текст",
|
||||
"analytics.system.title": "Статистика за системата",
|
||||
"analytics.system.totalBotPosts": "Общо публикации на ботове",
|
||||
"analytics.system.totalChannels": "Общо канали",
|
||||
"analytics.system.totalCommands": "Общо команди",
|
||||
"analytics.system.totalFilePosts": "Публикации с файлове",
|
||||
"analytics.system.totalHashtagPosts": "Публикации с хаштагове",
|
||||
"analytics.system.totalIncomingWebhooks": "Входящи webhook",
|
||||
"analytics.system.totalMasterDbConnections": "Връзки с главна БД",
|
||||
"analytics.system.totalOutgoingWebhooks": "Изходящи webhook",
|
||||
|
@ -246,6 +246,7 @@
|
||||
"add_teams_to_scheme.confirmation.message": "Tento tým je již vybrán v jiném týmovém schématu, jste si jisti, že ho chcete přesunout do tohoto týmového schématu?",
|
||||
"add_teams_to_scheme.confirmation.title": "Změna schématu přepsání pro tým?",
|
||||
"add_teams_to_scheme.modalTitle": "Přidat týmy do seznamu pro výběr týmu",
|
||||
"add_teams_to_scheme.select_team.label": "Zvolte tým {label}",
|
||||
"add_user_to_channel_modal.add": "Přidat",
|
||||
"add_user_to_channel_modal.cancel": "Zrušit",
|
||||
"add_user_to_channel_modal.help": "Zadejte pro vyhledání kanálu. Použijte ↑↓ pro procházení, ↵ pro výběr, ESC pro zavření.",
|
||||
@ -580,17 +581,17 @@
|
||||
"admin.complianceExport.createJob.title": "Spustit Export souladu s pravidly",
|
||||
"admin.complianceExport.exportFormat.actiance": "Actiance XML",
|
||||
"admin.complianceExport.exportFormat.csv": "CSV",
|
||||
"admin.complianceExport.exportFormat.globalrelay": "EML globálního přenositelného zákaznického účtu",
|
||||
"admin.complianceExport.exportFormat.globalrelay": "Global Relay EML",
|
||||
"admin.complianceExport.exportFormat.title": "Exportní formát:",
|
||||
"admin.complianceExport.exportFormatDetail.details": "Pro Actiance XML jsou soubory exportu pro dodržování předpisů ukládány do podadresáře exports v nastaveném <a>Místním úložišti</a>. Pro Global Relay EML jsou soubory zasílány na nastavenou e-mailovou adresu.",
|
||||
"admin.complianceExport.exportFormatDetail.intro": "„Formát exportu pro dodržování předpisů. Odpovídá systému, do kterého chcete data importovat.",
|
||||
"admin.complianceExport.exportJobStartTime.description": "Nastavte počáteční čas pravidelného denního exportu. Zvolte dobu, kdy bude váš systém používat nejméně lidí. Formát musí být 24 hodinový ve tvaru HH: MM.",
|
||||
"admin.complianceExport.exportJobStartTime.example": "Např.: \"02:00\"",
|
||||
"admin.complianceExport.exportJobStartTime.title": "Čas exportu zpráv o shodě:",
|
||||
"admin.complianceExport.globalRelayCustomSMTPPort.description": "Port SMTP serveru, který obdrží vaše EML globálního přenositelného zákaznického účtu.",
|
||||
"admin.complianceExport.globalRelayCustomSMTPPort.description": "SMTP serverový port, který přijme váš Global Relay EML.",
|
||||
"admin.complianceExport.globalRelayCustomSMTPPort.example": "Např.: \"25\"",
|
||||
"admin.complianceExport.globalRelayCustomSMTPPort.title": "Port SMTP serveru:",
|
||||
"admin.complianceExport.globalRelayCustomSMTPServerName.description": "Název SMTP serveru, který obdrží vaše EML globálního přenositelného zákaznického účtu.",
|
||||
"admin.complianceExport.globalRelayCustomSMTPServerName.description": "Název SMTP serveru, který přijme váš Global Relay EML.",
|
||||
"admin.complianceExport.globalRelayCustomSMTPServerName.example": "Např.: \"feeds.globalrelay.com\"",
|
||||
"admin.complianceExport.globalRelayCustomSMTPServerName.title": "Název SMTP serveru:",
|
||||
"admin.complianceExport.globalRelayCustomerType.a10.description": "A10/Typ 10",
|
||||
@ -598,7 +599,7 @@
|
||||
"admin.complianceExport.globalRelayCustomerType.custom.description": "Vlastní",
|
||||
"admin.complianceExport.globalRelayCustomerType.description": "Typ globálního přenositelného zákaznického účtu, který má vaše organizace k dispozici.",
|
||||
"admin.complianceExport.globalRelayCustomerType.title": "Globálně přenositelný zákaznický účet:",
|
||||
"admin.complianceExport.globalRelayEmailAddress.description": "E-mailová adresa, kterou váš globální přenosový server sleduje pro příchozí exporty souladu.",
|
||||
"admin.complianceExport.globalRelayEmailAddress.description": "E-mailová adresa, kterou váš server Global Relay sleduje pro příchozí exporty souladu.",
|
||||
"admin.complianceExport.globalRelayEmailAddress.example": "Např.: \"globalrelay@mattermost.com\"",
|
||||
"admin.complianceExport.globalRelayEmailAddress.title": "Email globálně přenositelného zákaznického účtu:",
|
||||
"admin.complianceExport.globalRelaySMTPPassword.description": "Heslo, které se používá k ověření proti serveru SMTP GlobalRelay.",
|
||||
@ -609,8 +610,8 @@
|
||||
"admin.complianceExport.globalRelaySMTPUsername.title": "Uživatelské jméno SMTP:",
|
||||
"admin.complianceExport.messagesExportedCount": "{count} zpráv vyexportováno.",
|
||||
"admin.complianceExport.title": "Export zprávy o shodě",
|
||||
"admin.complianceExport.warningCount": "{count} narazil(y) na varování, podrobnosti v souboru warning.txt",
|
||||
"admin.complianceExport.warningCount.globalrelay": "{count} narazil(y) na varování, podrobnosti v logu",
|
||||
"admin.complianceExport.warningCount": "{count} varování zjištěno, podrobnosti naleznete v souboru warning.txt",
|
||||
"admin.complianceExport.warningCount.globalrelay": "{count} varování zjištěno, podrobnosti naleznete v protokolu",
|
||||
"admin.complianceMonitoring.userActivityLogsTitle": "Záznamy aktivit uživatelů",
|
||||
"admin.compliance_export_feature_discovery.copy": "Spouštějte každodenní zprávy o compliance a exportujte je do různých formátů, které mohou být použity nástroji pro integraci třetích stran, jako je Smarsh (Actiance).",
|
||||
"admin.compliance_export_feature_discovery.title": "Provádění exportů v souladu s předpisy pomocí Mattermost Enterprise",
|
||||
@ -640,9 +641,9 @@
|
||||
"admin.compliance_table.type": "Typ",
|
||||
"admin.compliance_table.userId": "Požadováno kým",
|
||||
"admin.connectionSecurityNone": "Žádný",
|
||||
"admin.connectionSecurityNoneDescription": "Mattermost se bude připojovat přes nezabezpečené připojení.",
|
||||
"admin.connectionSecurityNoneDescription": "Mattermost se připojí přes nezabezpečené připojení.",
|
||||
"admin.connectionSecurityStart": "STARTTLS",
|
||||
"admin.connectionSecurityStartDescription": "Vezme stávající nezabezpečené připojení a pokusí se ho povýšit na zabezpečené připojení pomocí TLS.",
|
||||
"admin.connectionSecurityStartDescription": "Převede stávající nezabezpečené připojení a pokusí se jej upgradovat na zabezpečené připojení pomocí TLS.",
|
||||
"admin.connectionSecurityTitle": "Zabezpečení připojení:",
|
||||
"admin.connectionSecurityTls": "TLS",
|
||||
"admin.connectionSecurityTlsDescription": "Šifruje komunikaci mezi Mattermost a vaším serverem.",
|
||||
@ -652,8 +653,8 @@
|
||||
"admin.customization.allowSyncedDraftsDesc": "Pokud je tato funkce povolena, budou se návrhy zpráv uživatelů synchronizovat se serverem, aby k nim bylo možné přistupovat z jakéhokoli zařízení. Uživatelé mohou toto chování zrušit v nastavení účtu.",
|
||||
"admin.customization.androidAppDownloadLinkDesc": "Přidání odkazu ke stažení aplikace pro Android. Uživatelům, kteří přistupují k webu v mobilním webovém prohlížeči, bude zobrazena stránka, která jim umožní stáhnout aplikaci. Ponechte toto pole prázdné, abyste zabránili zobrazování stránky.",
|
||||
"admin.customization.androidAppDownloadLinkTitle": "Odkaz na Android aplikaci:",
|
||||
"admin.customization.announcement.allowBannerDismissalDesc": "Pokud je povoleno, mohou uživatelé odmítnout banner až do příští aktualizace. Pokud je zakázáno, banner je trvale viditelný, dokud ho systémový administrátor nevypne.",
|
||||
"admin.customization.announcement.allowBannerDismissalTitle": "Povolit schování banneru:",
|
||||
"admin.customization.announcement.allowBannerDismissalDesc": "Pokud je nastavena hodnota „true“, uživatelé mohou banner zavřít až do jeho další aktualizace. Pokud je nastavena hodnota „false“, banner zůstane trvale viditelný, dokud jej nevypne správce systému.",
|
||||
"admin.customization.announcement.allowBannerDismissalTitle": "Povolit zavření banneru:",
|
||||
"admin.customization.announcement.bannerColorTitle": "Barva banneru:",
|
||||
"admin.customization.announcement.bannerTextColorTitle": "Barva textu banneru:",
|
||||
"admin.customization.announcement.bannerTextDesc": "Text, který se objeví v oznamovacím banneru.",
|
||||
@ -663,20 +664,20 @@
|
||||
"admin.customization.appDownloadLinkDesc": "Přidat odkaz na stránku stažení Mattermost aplikací. Pokud odkaz existuje, možnost \"Stáhnout Mattermost aplikace\" bude přidána do nabídky produktů tak, že uživatelé budou moci nalézt stránku stažení. Nechte toto pole prázdné, aby možnost byla schována z nabídky produktů.",
|
||||
"admin.customization.appDownloadLinkTitle": "Odkaz na stránku s aplikacemi Mattermost:",
|
||||
"admin.customization.customUrlSchemes": "Vlastní schéma URL adresy:",
|
||||
"admin.customization.customUrlSchemesDesc": "Umožňuje z textu udělat odkaz, pokud začíná některým z uvedených schémat URL oddělených čárkou. Ve výchozím nastavení budou odkazy vytvářeny na následující schémata: \"http\", \"https\", \"ftp\", \"tel\" a \"mailto\".",
|
||||
"admin.customization.customUrlSchemesDesc": "Povolí textu zprávy vytvořit odkaz, pokud začíná některým z uvedených URL schémat oddělených čárkami. Ve výchozím nastavení budou následující schémata vytvářet odkazy: \"http\", \"https\", \"ftp\", \"tel\" a \"mailto\".",
|
||||
"admin.customization.customUrlSchemesPlaceholder": "Např.: \"git,smtp\"",
|
||||
"admin.customization.enableCustomEmojiDesc": "Umožnit uživatelům vytvářet vlastní emodži pro použití ve zprávách. Pokud je tato funkce povolena, lze k nastavení vlastních emodži přistupovat v kanálech prostřednictvím nástroje pro výběr emodži.",
|
||||
"admin.customization.enableCustomEmojiDesc": "Povolit uživatelům vytvářet vlastní emoji pro použití v zprávách. Po povolení budou nastavení vlastních emoji přístupná v kanálech prostřednictvím výběru emoji.",
|
||||
"admin.customization.enableCustomEmojiTitle": "Povolit vlastní emoji:",
|
||||
"admin.customization.enableDesktopLandingPageDesc": "Jestli chcete nebo nechcete nabízet uživateli použití Desktopové aplikace při prvním užití Mattermostu.",
|
||||
"admin.customization.enableDesktopLandingPageDesc": "Zda vyzvat uživatele k použití desktopové aplikace, když poprvé použije Mattermost.",
|
||||
"admin.customization.enableDesktopLandingPageTitle": "Povolit úvodní stránku desktopové aplikace:",
|
||||
"admin.customization.enableEmojiPickerDesc": "Výběr emodži umožňuje uživatelům vybrat emodži, které chtějí přidat jako reakci nebo použít ve zprávách. Povolení výběru emodži s velkým počtem vlastních emodži může zpomalit výkon.",
|
||||
"admin.customization.enableEmojiPickerTitle": "Povolit nabídku emotikonů:",
|
||||
"admin.customization.enableGifPickerDesc": "Povolit uživatelům volit GIFy z emodži pickeru.",
|
||||
"admin.customization.enableGifPickerTitle": "Povolit nabídku GIFů:",
|
||||
"admin.customization.enableInlineLatexDesc": "Povolení vykreslování inline kódu Latexu. Pokud je hodnota false, lze Latex vykreslovat pouze v bloku kódu pomocí zvýraznění syntaxe. Podrobnosti o formátování textu naleznete v naší <link>dokumentaci k webu </link> .",
|
||||
"admin.customization.enableInlineLatexTitle": "Povolte vykreslování Latex inline:",
|
||||
"admin.customization.enableInlineLatexTitle": "Povolit zobrazení inline LaTeXu:",
|
||||
"admin.customization.enableLatexDesc": "Povolení vykreslování Latexu v blocích kódu. Pokud je false, bude kód Latex pouze zvýrazněn.\n\nPovolení vykreslování Latexu není doporučeno v prostředích, kde nejsou všichni uživatelé věrohodní.",
|
||||
"admin.customization.enableLatexTitle": "Povolit vykreslování Latex:",
|
||||
"admin.customization.enableLatexTitle": "Povolit zobrazení LaTeXu:",
|
||||
"admin.customization.enableLinkPreviewsDesc": "Zobrazení náhledu obsahu webové stránky, odkazů na obrázky a odkazů na YouTube pod zprávou, pokud jsou k dispozici. Server musí být připojen k internetu a mít přístup přes bránu firewall (pokud je to možné) k webovým stránkám, od kterých se očekávají náhledy. Uživatelé mohou tyto náhledy zakázat v nabídce Nastavení > Zobrazení > Náhledy odkazů na webové stránky.",
|
||||
"admin.customization.enableLinkPreviewsTitle": "Povolení náhledů odkazů na webové stránky:",
|
||||
"admin.customization.enablePermalinkPreviewsDesc": "Pokud je tato funkce povolena, odkazy na zprávy Mattermostu vygenerují náhled pro všechny uživatele, kteří mají přístup k původní zprávě. Podrobnosti naleznete v naší dokumentaci na adrese <link></link> .",
|
||||
@ -685,47 +686,47 @@
|
||||
"admin.customization.enableSVGsTitle": "Povolte SVG:",
|
||||
"admin.customization.iosAppDownloadLinkDesc": "Přidání odkazu ke stažení iOS aplikace. Uživatelé, kteří navštíví stránku v mobilním prohlížeči budou odkázáni na stránku, která jim umožní stáhnout aplikaci. Ponechte toto pole prázdné, abyste zabránili zobrazování stránky.",
|
||||
"admin.customization.iosAppDownloadLinkTitle": "Odkaz ke stažení iOS aplikace:",
|
||||
"admin.customization.maxMarkdownNodesDesc": "Při renderování Markdown textu v mobilní aplikaci, kontrolovat maximální počet Markdown elementů (např. emodži, odkazy, tabulky, apod.) které mohou být v jednom kusu textu. Pokud je nastaveno 0, nebude limit použit.",
|
||||
"admin.customization.maxMarkdownNodesTitle": "Maximální počet Markdown nodů:",
|
||||
"admin.customization.maxMarkdownNodesDesc": "Při renderování textu v Markdown formátu v mobilní aplikaci určuje maximální počet prvků Markdownu (např. emoji, odkazy, buňky tabulek atd.), které mohou být v jednom textu. Pokud je nastaveno na 0, použije se výchozí limit.",
|
||||
"admin.customization.maxMarkdownNodesTitle": "Maximální počet uzlů Markdownu:",
|
||||
"admin.customization.restrictLinkPreviewsDesc": "Pro výše uvedený seznam domén oddělených čárkou se nezobrazí náhledy odkazů a náhledy obrázků.",
|
||||
"admin.customization.restrictLinkPreviewsExample": "Např.: \"internal.mycompany.com, images.example.com\"",
|
||||
"admin.customization.restrictLinkPreviewsTitle": "Zakázat náhledy odkazů na webové stránky z těchto domén:",
|
||||
"admin.customization.uniqueEmojiReactionLimitPerPost": "Počet reakčních unikátních emodži:",
|
||||
"admin.customization.uniqueEmojiReactionLimitPerPost.maxValue": "Hodnotu nelze zvýšit nad 500.",
|
||||
"admin.customization.uniqueEmojiReactionLimitPerPost.minValue": "Hodnotu nelze nastavit níž, než 0.",
|
||||
"admin.customization.uniqueEmojiReactionLimitPerPostDesc": "Počet jedinečných reakcí emodži, které lze přidat k příspěvku. Zvýšení tohoto limitu by mohlo vést ke špatnému výkonu klienta. Maximum je 500.",
|
||||
"admin.customization.uniqueEmojiReactionLimitPerPost": "Limit pro jedinečné reakce emoji:",
|
||||
"admin.customization.uniqueEmojiReactionLimitPerPost.maxValue": "Nelze zvýšit limit na hodnotu vyšší než 500.",
|
||||
"admin.customization.uniqueEmojiReactionLimitPerPost.minValue": "Nelze snížit limit pod 0.",
|
||||
"admin.customization.uniqueEmojiReactionLimitPerPostDesc": "Počet unikátních emoji reakcí, které mohou být přidány k příspěvku. Zvýšení tohoto limitu může vést k horší výkonnosti klienta. Maximální hodnota je 500.",
|
||||
"admin.customization.uniqueEmojiReactionLimitPerPostPlaceholder": "Např.: 25",
|
||||
"admin.data_grid.empty": "Nebyly nalezeny žádné položky",
|
||||
"admin.data_grid.loading": "Nahrávám",
|
||||
"admin.data_grid.paginatorCount": "{startCount, number} - {endCount, number} z {total, number}",
|
||||
"admin.data_retention.channel_team_counts": "{team_count} {team_count, plural, one {tým} other {týmy}}, {channel_count} {channel_count, plural, one {kanál} other {kanály}}",
|
||||
"admin.data_retention.channel_team_counts_empty": "NEUPLATŇUJE SE",
|
||||
"admin.data_retention.createJob.instructions": "Denní čas pro kontrolu zásad a spuštění úlohy mazání:",
|
||||
"admin.data_retention.createJob.title": "Spustit úlohu odstranění",
|
||||
"admin.data_retention.channel_team_counts_empty": "N/A",
|
||||
"admin.data_retention.createJob.instructions": "Denní čas pro kontrolu zásad a spuštění úkolu pro smazání:",
|
||||
"admin.data_retention.createJob.title": "Spustit úkol pro smazání nyní",
|
||||
"admin.data_retention.customPolicies.addPolicy": "Přidat zásady",
|
||||
"admin.data_retention.customPolicies.subTitle": "Přizpůsobte si, jak dlouho budou konkrétní týmy a kanály uchovávat zprávy.",
|
||||
"admin.data_retention.customPolicies.title": "Vlastní zásady uchovávání dat",
|
||||
"admin.data_retention.customPoliciesTable.appliedTo": "Aplikováno na",
|
||||
"admin.data_retention.customPolicies.title": "Vlastní zásady uchovávání",
|
||||
"admin.data_retention.customPoliciesTable.appliedTo": "Použito na",
|
||||
"admin.data_retention.customPoliciesTable.channelMessages": "Zprávy kanálu",
|
||||
"admin.data_retention.customPoliciesTable.description": "Popis",
|
||||
"admin.data_retention.customTitle": "Vlastní zásady uchovávání dat",
|
||||
"admin.data_retention.customTitle": "Vlastní Zásady Uchovávání",
|
||||
"admin.data_retention.custom_policy.cancel": "Zrušit",
|
||||
"admin.data_retention.custom_policy.channel_selector.addChannels": "Přidání kanálů",
|
||||
"admin.data_retention.custom_policy.channel_selector.subTitle": "Přidejte kanály, které se budou řídit touto zásadou uchovávání.",
|
||||
"admin.data_retention.custom_policy.channel_selector.addChannels": "Přidat kanály",
|
||||
"admin.data_retention.custom_policy.channel_selector.subTitle": "Přidat kanály, které budou následovat tuto politiku uchovávání dat.",
|
||||
"admin.data_retention.custom_policy.channel_selector.title": "Přiřazené kanály",
|
||||
"admin.data_retention.custom_policy.form.durationInput.error": "Chybné zpracování zprávy o uchování.",
|
||||
"admin.data_retention.custom_policy.form.durationInput.error": "Chyba při analýze uchovávání zpráv.",
|
||||
"admin.data_retention.custom_policy.form.input": "Název politiky",
|
||||
"admin.data_retention.custom_policy.form.input.error": "Název zásady nemůže být prázdný.",
|
||||
"admin.data_retention.custom_policy.form.subTitle": "Pojmenujte zásady a nakonfigurujte nastavení uchovávání.",
|
||||
"admin.data_retention.custom_policy.form.teamsError": "Do zásady je třeba přidat tým nebo kanál.",
|
||||
"admin.data_retention.custom_policy.form.input.error": "Název politiky nemůže být prázdný.",
|
||||
"admin.data_retention.custom_policy.form.subTitle": "Dejte své politice název a nastavte pravidla uchovávání dat.",
|
||||
"admin.data_retention.custom_policy.form.teamsError": "Musíte přidat tým nebo kanál do této politiky.",
|
||||
"admin.data_retention.custom_policy.form.title": "Jméno a uchovávání",
|
||||
"admin.data_retention.custom_policy.save": "Uložit",
|
||||
"admin.data_retention.custom_policy.serverError": "Ve formuláři výše se vyskytly chyby",
|
||||
"admin.data_retention.custom_policy.team_selector.addTeams": "Přidat týmy",
|
||||
"admin.data_retention.custom_policy.team_selector.subTitle": "Přidejte kanály, které se budou řídit touto zásadou uchovávání.",
|
||||
"admin.data_retention.custom_policy.team_selector.title": "Přidělené týmy",
|
||||
"admin.data_retention.custom_policy.team_selector.title": "Přiřazené týmy",
|
||||
"admin.data_retention.custom_policy.teams.remove": "Odstranit",
|
||||
"admin.data_retention.form.channelAndDirectMessageRetention": "Udržování kanálů a přímých zpráv",
|
||||
"admin.data_retention.form.channelAndDirectMessageRetention": "Retence kanálů a přímých zpráv",
|
||||
"admin.data_retention.form.days": "Dny",
|
||||
"admin.data_retention.form.fileRetention": "Uchovávání souborů",
|
||||
"admin.data_retention.form.hours": "Hodiny",
|
||||
@ -750,7 +751,7 @@
|
||||
"admin.data_retention.retention_years": "{count} {count, plural, one {rok} other {roky}}",
|
||||
"admin.data_retention.settings.title": "Zásady uchovávání údajů",
|
||||
"admin.data_retention.title": "Zásady uchovávání údajů",
|
||||
"admin.data_retention_feature_discovery.copy": "Data uchovávejte jen tak dlouho, jak potřebujete. Vytvořte úlohy pro uchovávání dat pro vybrané kanály a týmy, abyste automaticky odstranili jednorázová data.",
|
||||
"admin.data_retention_feature_discovery.copy": "Uchovávejte svá data pouze tak dlouho, jak je potřebujete. Vytvořte úkoly pro uchovávání dat pro vybrané kanály a týmy, které automaticky odstraní nepotřebná data.",
|
||||
"admin.data_retention_feature_discovery.title": "Vytváření plánů uchovávání dat pomocí Mattermost Enterprise",
|
||||
"admin.database.migrations_table.help_text": "Seznam migrací schémat použitých na váš datový sklad.",
|
||||
"admin.database.migrations_table.name": "Jméno",
|
||||
@ -1494,6 +1495,7 @@
|
||||
"admin.logs.showErrors": "Zobrazit posledních {n} chyb",
|
||||
"admin.logs.title": "Serverové záznamy",
|
||||
"admin.manage_roles.additionalRoles": "Vyberte další oprávnění pro účet. <link>Dozvědět se více o rolích a oprávněních</link>.",
|
||||
"admin.manage_roles.additionalRoles_warning": "<strong>Poznámka: </strong><span>Výše udělená oprávnění platí pro celý účet, bez ohledu na to, zda je ověřen pomocí relace cookie nebo osobního přístupového tokenu. Například výběr post:all umožní účtu přispívat do kanálů, jejichž není členem, i bez použití osobního přístupového tokenu.</span>",
|
||||
"admin.manage_roles.allowUserAccessTokens": "Povolit tomuto účtu generovat <link>osobní přístupové tokeny</link>.",
|
||||
"admin.manage_roles.allowUserAccessTokensDesc": "Zrušení tohoto oprávnění neodstraní existující tokeny. K jejich odstranění musíte přejít na stránku Správa tokenů uživatele.",
|
||||
"admin.manage_roles.botAdditionalRoles": "Vyberte další oprávnění pro účet. <link>Dozvědět se více o rolích a oprávněních</link>.",
|
||||
@ -1594,8 +1596,10 @@
|
||||
"admin.password.preview": "Náhled chybové zprávy",
|
||||
"admin.password.symbol": "Alespoň jeden symbol (např. \"~!@#$%^&*()\")",
|
||||
"admin.password.uppercase": "Alespoň jedno velké písmeno",
|
||||
"admin.permissions.group.convert_private_channel_to_public.description": "Převést soukromé kanály na veřejné",
|
||||
"admin.permissions.group.convert_private_channel_to_public.name": "Převést na veřejné",
|
||||
"admin.permissions.group.convert_public_channel_to_private.description": "Převést veřejné kanály na privátní",
|
||||
"admin.permissions.group.convert_public_channel_to_private.name": "Převést kanály",
|
||||
"admin.permissions.group.convert_public_channel_to_private.name": "Převést na soukromé",
|
||||
"admin.permissions.group.custom_groups.description": "Vytvoření, úprava, odstranění a správa členů vlastních skupin.",
|
||||
"admin.permissions.group.custom_groups.name": "Vlastní skupiny",
|
||||
"admin.permissions.group.delete_posts.description": "Smazat příspěvky vlastní a ostatních.",
|
||||
@ -1655,9 +1659,9 @@
|
||||
"admin.permissions.permission.assign_system_admin_role.description": "Přiřadit roli správce systému",
|
||||
"admin.permissions.permission.assign_system_admin_role.name": "Přiřadit roli správce systému",
|
||||
"admin.permissions.permission.convert_private_channel_to_public.description": "Převést soukromé kanály na veřejné",
|
||||
"admin.permissions.permission.convert_private_channel_to_public.name": "Převést kanály",
|
||||
"admin.permissions.permission.convert_private_channel_to_public.name": "Převést na veřejné",
|
||||
"admin.permissions.permission.convert_public_channel_to_private.description": "Převést veřejné kanály na soukromé",
|
||||
"admin.permissions.permission.convert_public_channel_to_private.name": "Převést kanály",
|
||||
"admin.permissions.permission.convert_public_channel_to_private.name": "Převést na soukromé",
|
||||
"admin.permissions.permission.create_custom_group.description": "Vytvořit vlastní skupiny.",
|
||||
"admin.permissions.permission.create_custom_group.name": "Vytvořit",
|
||||
"admin.permissions.permission.create_direct_channel.description": "Vytvořit přímou zprávu",
|
||||
@ -1883,7 +1887,8 @@
|
||||
"admin.permissions.sysconsole_section_user_management_channels.name": "Kanály",
|
||||
"admin.permissions.sysconsole_section_user_management_groups.name": "Skupiny",
|
||||
"admin.permissions.sysconsole_section_user_management_permissions.name": "Oprávnění",
|
||||
"admin.permissions.sysconsole_section_user_management_system_roles.name": "Systémové role",
|
||||
"admin.permissions.sysconsole_section_user_management_system_roles.description": "Nastavení „Žádný přístup“ omezuje pouze rozhraní Systémové konzole. Základní API koncové body zůstávají přístupné všem uživatelům v režimu pouze pro čtení pro základní funkčnost produktu.",
|
||||
"admin.permissions.sysconsole_section_user_management_system_roles.name": "Delegovaná granulární správa",
|
||||
"admin.permissions.sysconsole_section_user_management_teams.name": "Týmy",
|
||||
"admin.permissions.sysconsole_section_user_management_users.description": "Nemohu resetovat hesla adminům",
|
||||
"admin.permissions.sysconsole_section_user_management_users.name": "Uživatelé",
|
||||
@ -2486,6 +2491,7 @@
|
||||
"admin.sidebar.smtp": "SMTP",
|
||||
"admin.sidebar.subscription": "Předplatné",
|
||||
"admin.sidebar.systemRoles": "Delegovaná podrobná administrace",
|
||||
"admin.sidebar.system_properties": "Systémové vlastnosti",
|
||||
"admin.sidebar.teamStatistics": "Statistiky týmu",
|
||||
"admin.sidebar.teams": "Týmy",
|
||||
"admin.sidebar.userManagement": "Správa uživatelů",
|
||||
@ -2562,6 +2568,23 @@
|
||||
"admin.systemUserDetail.teamList.teamType.groupSync": "Synchronizace skupiny",
|
||||
"admin.systemUserDetail.teamList.teamType.inviteOnly": "Pouze na pozvání",
|
||||
"admin.systemUserDetail.title": "Nastavení uživatele",
|
||||
"admin.system_properties.confirm.delete.button": "Smazat",
|
||||
"admin.system_properties.confirm.delete.text": "Smazání této vlastnosti odstraní všechny hodnoty definované uživatelem, které jsou s ní spojeny.",
|
||||
"admin.system_properties.confirm.delete.title": "Smazat vlastnost {name}",
|
||||
"admin.system_properties.details.saving_changes": "Ukládám nastavení…",
|
||||
"admin.system_properties.details.saving_changes_error": "Při ukládání nastavení se vyskytla chyba",
|
||||
"admin.system_properties.user_properties.add_property": "Přidat vlastnost",
|
||||
"admin.system_properties.user_properties.subtitle": "Přizpůsobte vlastnosti, které se zobrazí v uživatelských profilech",
|
||||
"admin.system_properties.user_properties.table.actions": "Úkony",
|
||||
"admin.system_properties.user_properties.table.actions.delete": "Smazat",
|
||||
"admin.system_properties.user_properties.table.property": "Vlastnost",
|
||||
"admin.system_properties.user_properties.table.property_name.input.name": "Název vlastnosti",
|
||||
"admin.system_properties.user_properties.table.type": "Typ",
|
||||
"admin.system_properties.user_properties.table.type.text": "Text",
|
||||
"admin.system_properties.user_properties.table.validation.name_required": "Prosím, zadejte název vlastnosti.",
|
||||
"admin.system_properties.user_properties.table.validation.name_taken": "Název vlastnosti již existuje.",
|
||||
"admin.system_properties.user_properties.table.validation.name_unique": "Název vlastnosti musí být jedinečný.",
|
||||
"admin.system_properties.user_properties.title": "Vlastnosti uživatele",
|
||||
"admin.system_roles_feature_discovery.copy": "Přiřaďte přizpůsobitelné administrátorské role, abyste vybraným uživatelům poskytli přístup pro čtení/ zápis k vybraným sekcím systémové konzole.",
|
||||
"admin.system_roles_feature_discovery.title": "Poskytněte kontrolovaný přístup do konzoly systému s Mattermost Enterprise",
|
||||
"admin.system_users.column_toggler.dropdownAriaLabel": "Nabídka viditelnosti sloupců",
|
||||
@ -2885,13 +2908,10 @@
|
||||
"analytics.system.publicChannels": "Veřejné kanály",
|
||||
"analytics.system.seatsPurchased": "Licencovaná místa",
|
||||
"analytics.system.skippedIntensiveQueries": "Pro maximalizaci výkonu jsou některé statistiky vypnuty. Můžete <link>znovu povolit v konfiguračním souboru config.json</link>.",
|
||||
"analytics.system.textPosts": "Pouze textové příspěvky",
|
||||
"analytics.system.title": "Statistiky systemu",
|
||||
"analytics.system.totalBotPosts": "Množstvý zpráv od botů",
|
||||
"analytics.system.totalChannels": "Celkový počet kanálů",
|
||||
"analytics.system.totalCommands": "Celkový počet příkazů",
|
||||
"analytics.system.totalFilePosts": "Příspěvky se soubory",
|
||||
"analytics.system.totalHashtagPosts": "Zprávy s hashtagamy",
|
||||
"analytics.system.totalIncomingWebhooks": "Příchozí webhooky",
|
||||
"analytics.system.totalMasterDbConnections": "Celkový počet připojení k hlavní databázi",
|
||||
"analytics.system.totalOutgoingWebhooks": "Odchozí webové háčky",
|
||||
@ -3211,9 +3231,11 @@
|
||||
"channel_header.closeChannelInfo": "Skrýt informace",
|
||||
"channel_header.convert": "Převést na soukromý kanál",
|
||||
"channel_header.delete": "Archivovat kanál",
|
||||
"channel_header.directchannel": "{displayName} (vy) Menu kanálu",
|
||||
"channel_header.directchannel.you": "{displayname} (já) ",
|
||||
"channel_header.flagged": "Uložené zprávy",
|
||||
"channel_header.groupMessageHasGuests": "Tato skupinová zpráva má návštěvníky",
|
||||
"channel_header.headerText.addNewButton": "Přidat záhlaví kanálu",
|
||||
"channel_header.lastActive": "Aktivní {timestamp}",
|
||||
"channel_header.lastOnline": "Naposledy online {timestamp}",
|
||||
"channel_header.leave": "Opustit kanál",
|
||||
@ -3222,6 +3244,7 @@
|
||||
"channel_header.mute": "Ztlumit kanál",
|
||||
"channel_header.muteConversation": "Ztlumit konverzaci",
|
||||
"channel_header.openChannelInfo": "Zobrazit info",
|
||||
"channel_header.otherchannel": "{displayName} Menu kanálu",
|
||||
"channel_header.pinnedPosts": "Připnuté zprávy",
|
||||
"channel_header.recentMentions": "Nedávné zmínky",
|
||||
"channel_header.rename": "Přejmenovat kanál",
|
||||
@ -3259,6 +3282,7 @@
|
||||
"channel_info_rhs.menu.members": "Členové",
|
||||
"channel_info_rhs.menu.notification_preferences": "Předvolby oznámení",
|
||||
"channel_info_rhs.menu.pinned": "Připnuté zprávy",
|
||||
"channel_info_rhs.menu.title": "Akce informací o kanálu",
|
||||
"channel_info_rhs.top_buttons.add_people": "Přidat lidi",
|
||||
"channel_info_rhs.top_buttons.add_people.tooltip": "Přidat člena týmu do tohoto kanálu",
|
||||
"channel_info_rhs.top_buttons.copied": "Zkopírováno",
|
||||
@ -3683,6 +3707,8 @@
|
||||
"email_verify.return": "Vraťte se k přihlášení",
|
||||
"email_verify.sending": "Odesílání e-mailu…",
|
||||
"email_verify.sent": "Ověřovací e-mail odeslán",
|
||||
"emoji_gif_picker.dialog.emojis": "Výběr emoji",
|
||||
"emoji_gif_picker.dialog.gifs": "Výběr GIF",
|
||||
"emoji_gif_picker.tabs.emojis": "Emodži",
|
||||
"emoji_gif_picker.tabs.gifs": "GIF obrázky",
|
||||
"emoji_list.actions": "Akce",
|
||||
@ -4329,6 +4355,7 @@
|
||||
"mobile.set_status.dnd.icon": "Ikona nerušit",
|
||||
"mobile.set_status.offline.icon": "Offline ikona",
|
||||
"mobile.set_status.online.icon": "Ikona připojen",
|
||||
"modal.header_close": "Zavřít",
|
||||
"modal.manual_status.ask": "Příště se neptat",
|
||||
"modal.manual_status.auto_responder.message_away": "Chceš nastavit svůj status na \"Pryč\" a zrušit automatické odpovědi?",
|
||||
"modal.manual_status.auto_responder.message_dnd": "Chceš nastavit svůj status na \"Nerušit\" a vypnout automatické odpovědi?",
|
||||
@ -4377,6 +4404,7 @@
|
||||
"more_channels.view": "Zobrazit",
|
||||
"more_direct_channels.directchannel.deactivated": "{displayname} – Deaktivováno",
|
||||
"more_direct_channels.directchannel.you": "{displayname} (Vy)",
|
||||
"more_direct_channels.new_convo_add.label": "Přidat možnost {label}",
|
||||
"more_direct_channels.new_convo_note": "Tím zahájíte novou konverzaci. Pokud přidáváte hodně lidí, zvažte vytvoření soukromého kanálu.",
|
||||
"more_direct_channels.new_convo_note.full": "Dosáhli jste maximálního počtu lidí pro tuto konverzaci. Zvažte místo toho vytvoření soukromého kanálu.",
|
||||
"more_direct_channels.title": "Přímé zprávy",
|
||||
@ -4516,6 +4544,7 @@
|
||||
"onboardingTask.checklist.main_subtitle": "Pořádně se pusťme do toho.",
|
||||
"onboardingTask.checklist.no_thanks": "Ne, děkuji",
|
||||
"onboardingTask.checklist.start_enterprise_now": "Vyzkoušejte si Enterprise edici zdarma!",
|
||||
"onboardingTask.checklist.start_onboarding_process": "Zahájit proces onboardingu.",
|
||||
"onboardingTask.checklist.task_complete_your_profile": "Vyplňte svůj profil.",
|
||||
"onboardingTask.checklist.task_download_mm_apps": "Stáhněte si desktopovou a mobilní aplikaci.",
|
||||
"onboardingTask.checklist.task_invite_team_members": "Pozvěte členy týmu do pracovního prostoru.",
|
||||
@ -4628,6 +4657,9 @@
|
||||
"post.ariaLabel.replyMessage": "{authorName} odpověděl(a), {message}, dne {date} v {time}",
|
||||
"post.reminder.acknowledgement": "V {reminderTime}, {reminderDate} vám bude připomenuta tato zpráva od uživatele {username}: {permaLink}",
|
||||
"post.reminder.systemBot": "Dobrý den, zde je vaše připomenutí ohledně této zprávy od uživatele {username}: {permaLink}",
|
||||
"post.renderError.message": "Došlo k chybě při vykreslování tohoto příspěvku.",
|
||||
"post.renderError.retry": "Opakovat",
|
||||
"post.renderError.retryLabel": "Opakovat vykreslení tohoto příspěvku",
|
||||
"post_body.check_for_out_of_channel_groups_mentions.message": "nebyli notifikováni tímto zmiňováním, protože nejsou v tomto kanálu. Nemohou být přidáni do kanálu, protože nejsou členy propojených skupin. Pro přidání jich do tohoto kanálu musí být přidáni do jedné z propojených skupin.",
|
||||
"post_body.check_for_out_of_channel_mentions.link.and": " a ",
|
||||
"post_body.check_for_out_of_channel_mentions.link.private": "přidejte je k tomuto soukromému kanálu",
|
||||
@ -4784,6 +4816,8 @@
|
||||
"pricing_modal.title": "Vybrat plán",
|
||||
"pricing_modal.wantToTry": "Chcete zkusit? ",
|
||||
"pricing_modal.wantToUpgrade": "Chcete upgradovat? ",
|
||||
"profile_popover.aria_label.with_username": "Vyskakovací okno profilu {userName}",
|
||||
"profile_popover.aria_label.without_username": "vyskakovací okno profilu",
|
||||
"promote_to_user_modal.desc": "Tato akce povýší hosta {username} na člena. To umožní uživateli připojit se k veřejným kanálům a komunikovat s uživateli mimo kanály, kterých je aktuálně členem. Jste si jisti, že chcete povýšit hosta {username} na člena?",
|
||||
"promote_to_user_modal.promote": "Povýšit",
|
||||
"promote_to_user_modal.title": "Povýšit hosta {username} na člena",
|
||||
@ -4937,6 +4971,7 @@
|
||||
"search_hint.enter_to_search": "Stiskněte Enter pro vyhledávání",
|
||||
"search_hint.enter_to_select": "Stiskněte Enter pro výběr",
|
||||
"search_hint.filter": "Filtrovat hledání pomocí:",
|
||||
"search_hint.reset_filters": "Filtry byly nastaveny na výchozí hodnoty, protože jste vybrali jiný tým",
|
||||
"search_item.channelArchived": "Archivováno",
|
||||
"search_item.direct": "Přímá zpráva (od {username})",
|
||||
"search_item.file_tag.direct_message": "Přímá zpráva",
|
||||
@ -4952,6 +4987,9 @@
|
||||
"search_list_option.on": "Příspěvky v datu",
|
||||
"search_list_option.phrases": "Příspěvky s frázemi",
|
||||
"search_results.channel-files-header": "Nedávné soubory",
|
||||
"search_teams_selector.all_teams": "Všechny Týmy",
|
||||
"search_teams_selector.search_teams": "Vyhledat týmy",
|
||||
"search_teams_selector.your_teams": "Vaše týmy",
|
||||
"sectionNotice.dismiss": "Skrýt oznámení",
|
||||
"select_team.icon": "Ikona Výběr týmu",
|
||||
"select_team.join.icon": "Ikona Připojit se k týmu",
|
||||
@ -5120,6 +5158,14 @@
|
||||
"sidebar.types.favorites": "OBLÍBENÉ",
|
||||
"sidebar.types.unreads": "NEPŘEČTENÉ",
|
||||
"sidebar.unreads": "Více nepřečtených",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.browseChannelsMenuItem.primaryLabel": "Procházet Kanály",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.createCategoryMenuItem.primaryLabel": "Vytvořit Novou Kategorii",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.createNewChannelMenuItem.primaryLabel": "Vytvořit Nový Kanál",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.createUserGroupMenuItem.primaryLabel": "Vytvořit novou skupinu uživatelů",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.invitePeopleMenuItem.primaryLabel": "Pozvat Lidi",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.invitePeopleMenuItem.secondaryLabel": "Přidat lidi do týmu",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.openDirectMessageMenuItem.primaryLabel": "Otevřít přímou zprávu",
|
||||
"sidebarLeft.browserOrCreateChannelMenuButton.label": "Procházet nebo vytvořit kanály",
|
||||
"sidebar_left.addChannelsCta": "Přidat kanály",
|
||||
"sidebar_left.add_channel_cta_dropdown.dropdownAriaLabel": "Rozbalovací nabídka Přidat kanál",
|
||||
"sidebar_left.add_channel_dropdown.browseChannels": "Prohlížet kanály",
|
||||
@ -5535,6 +5581,7 @@
|
||||
"user.settings.display.theme.title": "Téma",
|
||||
"user.settings.display.timezone": "Časové pásmo",
|
||||
"user.settings.display.title": "Nastavení zobrazení",
|
||||
"user.settings.general.attributeExtra": "Toto se zobrazí v náhledu vašeho profilu.",
|
||||
"user.settings.general.close": "Zavřít",
|
||||
"user.settings.general.confirmEmail": "Potvrdit e-mail",
|
||||
"user.settings.general.currentEmail": "Aktuální e-mail",
|
||||
@ -5549,6 +5596,7 @@
|
||||
"user.settings.general.emailOffice365CantUpdate": "Přihlášení probíhá prostřednictvím Entra ID. E-mail nelze změnit. E-mailová adresa používaná pro upozornění je {email}.",
|
||||
"user.settings.general.emailOpenIdCantUpdate": "Přihlášení se děje přes OpenID Connect. Email nelze změnit. Emailová adresa použita pro notifikace je {email}.",
|
||||
"user.settings.general.emailSamlCantUpdate": "Přihlášení probíhá prostřednictvím služby GitLab. E-mail nelze zmněnit. E-mailová adresa používaná pro upozornění je {email}.",
|
||||
"user.settings.general.emptyAttribute": "Klikněte na 'Upravit' a přidejte svůj vlastní atribut",
|
||||
"user.settings.general.emptyName": "Klikněte pro přidání svého celého jména",
|
||||
"user.settings.general.emptyNickname": "Klepněte na tlačítko \"Upravit\" pro přidání přezdívky",
|
||||
"user.settings.general.emptyPassword": "Prosím zadejte své aktuální heslo.",
|
||||
@ -5565,6 +5613,7 @@
|
||||
"user.settings.general.loginLdap": "Přihlášení se provádí prostřednictvím AD/LDAP ({email})",
|
||||
"user.settings.general.loginOffice365": "Přihlášeno přes Entra ID ({email})",
|
||||
"user.settings.general.loginSaml": "Přihlášení se provádí prostřednictvím Office 365 ({email})",
|
||||
"user.settings.general.mobile.emptyAttribute": "Klikněte pro přidání vlastního atributu",
|
||||
"user.settings.general.mobile.emptyName": "Klikněte pro přidání svého celého jména",
|
||||
"user.settings.general.mobile.emptyNickname": "Klepněte pro přidání přezdívky",
|
||||
"user.settings.general.mobile.emptyPosition": "Klikněte pro přidání svého pracovního titulu / pozice",
|
||||
@ -5824,6 +5873,39 @@
|
||||
"user.settings.tokens.tokenId": "ID tokenu: ",
|
||||
"user.settings.tokens.tokenLoading": "Nahrávám...",
|
||||
"user.settings.tokens.userAccessTokensNone": "Žádné osobní přístupové tokeny.",
|
||||
"userAccountMenu.awayMenuItem.label": "Pryč",
|
||||
"userAccountMenu.dndMenuItem.primaryLabel": "Režim nerušit",
|
||||
"userAccountMenu.dndMenuItem.secondaryLabel": "Vypnout všechna upozornění",
|
||||
"userAccountMenu.dndMenuItem.secondaryLabel.doNotClear": "Do nekonečna",
|
||||
"userAccountMenu.dndMenuItem.secondaryLabel.untilLaterSomeTime": "Do {time}",
|
||||
"userAccountMenu.dndMenuItem.secondaryLabel.untilTodaySomeTime": "Do {time}",
|
||||
"userAccountMenu.dndMenuItem.secondaryLabel.untilTomorrowSomeTime": "Do zítra {time}",
|
||||
"userAccountMenu.dndSubMenu.title": "Smazat po:",
|
||||
"userAccountMenu.dndSubMenuItem.1Hour": "1 hodina",
|
||||
"userAccountMenu.dndSubMenuItem.2Hours": "2 hodiny",
|
||||
"userAccountMenu.dndSubMenuItem.30Minutes": "30 min",
|
||||
"userAccountMenu.dndSubMenuItem.custom": "Zvolte datum a čas",
|
||||
"userAccountMenu.dndSubMenuItem.doNotClear": "Nemazat",
|
||||
"userAccountMenu.dndSubMenuItem.tomorrow": "Zítra",
|
||||
"userAccountMenu.dndSubMenuItem.tomorrowsDateTime": "{shortDay}, {shortTime}",
|
||||
"userAccountMenu.logoutMenuItem.label": "Odhlásit Se",
|
||||
"userAccountMenu.menuButton.ariaDescription.away": "Stav je \"Pryč\".",
|
||||
"userAccountMenu.menuButton.ariaDescription.dnd": "Stav je \"Nerušit\".",
|
||||
"userAccountMenu.menuButton.ariaDescription.offline": "Stav je \"Offline\".",
|
||||
"userAccountMenu.menuButton.ariaDescription.online": "Stav je \"Online\".",
|
||||
"userAccountMenu.menuButton.ariaDescription.ooo": "Stav je \"Mimo kancelář\".",
|
||||
"userAccountMenu.menuButton.ariaLabel": "Menu účtu uživatele",
|
||||
"userAccountMenu.offlineMenuItem.label": "Offline",
|
||||
"userAccountMenu.onlineMenuItem.label": "Online",
|
||||
"userAccountMenu.oooMenuItem.primaryLabel": "Mimo kancelář",
|
||||
"userAccountMenu.oooMenuItem.secondaryLabel": "Automatické odpovědi jsou zapnuty",
|
||||
"userAccountMenu.profileMenuItem.label": "Profil",
|
||||
"userAccountMenu.setCustomStatusMenuItem.clearTooltip": "Smazat vlastní stav",
|
||||
"userAccountMenu.setCustomStatusMenuItem.hasStatusWithExpiryAndNoText.ariaDescription": "Stav exspiruje v {time}. Nastavit vlastní stav.",
|
||||
"userAccountMenu.setCustomStatusMenuItem.hasStatusWithTextAndExpiry.ariaDescription": "Stav je \"{text}\" a vyprší v {time}. Nastavte vlastní status.",
|
||||
"userAccountMenu.setCustomStatusMenuItem.hasStatusWithTextAndNoExpiry.ariaDescription": "Stav je \"{text}\". Nastavte vlastní status.",
|
||||
"userAccountMenu.setCustomStatusMenuItem.noStatusSet": "Nastavte vlastní stav",
|
||||
"userAccountMenu.setCustomStatusMenuItem.noStatusTextSet": "Nastavte text vlastního stavu",
|
||||
"userGuideHelp.askTheCommunity": "Zeptejte se komunity",
|
||||
"userGuideHelp.helpResources": "Zdroje nápovědy",
|
||||
"userGuideHelp.keyboardShortcuts": "Klávesové zkratky",
|
||||
|
@ -1887,7 +1887,8 @@
|
||||
"admin.permissions.sysconsole_section_user_management_channels.name": "Kanäle",
|
||||
"admin.permissions.sysconsole_section_user_management_groups.name": "Gruppen",
|
||||
"admin.permissions.sysconsole_section_user_management_permissions.name": "Berechtigungen",
|
||||
"admin.permissions.sysconsole_section_user_management_system_roles.name": "Systemrollen",
|
||||
"admin.permissions.sysconsole_section_user_management_system_roles.description": "Die Einstellung \"Kein Zugriff\" schränkt nur die Oberfläche der Systemkonsole ein. Die zugrundeliegenden API-Endpunkte sind für alle Benutzer in einem Nur-Lese-Status für grundlegende Produktfunktionen zugänglich.",
|
||||
"admin.permissions.sysconsole_section_user_management_system_roles.name": "Delegierte differenzierte Verwaltung",
|
||||
"admin.permissions.sysconsole_section_user_management_teams.name": "Teams",
|
||||
"admin.permissions.sysconsole_section_user_management_users.description": "Kann Admin Passwörter nicht zurücksetzen",
|
||||
"admin.permissions.sysconsole_section_user_management_users.name": "Benutzer",
|
||||
@ -2577,9 +2578,11 @@
|
||||
"admin.system_properties.user_properties.table.actions": "Aktionen",
|
||||
"admin.system_properties.user_properties.table.actions.delete": "Löschen",
|
||||
"admin.system_properties.user_properties.table.property": "Eigenschaft",
|
||||
"admin.system_properties.user_properties.table.property_name.input.name": "Eigenschaftsname",
|
||||
"admin.system_properties.user_properties.table.type": "Typ",
|
||||
"admin.system_properties.user_properties.table.type.text": "Text",
|
||||
"admin.system_properties.user_properties.table.validation.name_required": "Bitte gib einen Eigenschaftsnamen ein.",
|
||||
"admin.system_properties.user_properties.table.validation.name_taken": "Eigenschaftsname ist bereits vergeben.",
|
||||
"admin.system_properties.user_properties.table.validation.name_unique": "Eigenschaftsnamen müssen eindeutig sein.",
|
||||
"admin.system_properties.user_properties.title": "Benutzer-Eigenschaften",
|
||||
"admin.system_roles_feature_discovery.copy": "Verwende anpassbare Verwaltungsrollen um ausgesuchten Benutzern Lese- und/oder Schreibzugriff auf ausgewählte Sektionen der Systemkonsole zu geben.",
|
||||
@ -2905,13 +2908,10 @@
|
||||
"analytics.system.publicChannels": "Öffentliche Kanäle",
|
||||
"analytics.system.seatsPurchased": "Lizensierte Sitze",
|
||||
"analytics.system.skippedIntensiveQueries": "Um die Performance zu maximieren, sind einige Statistiken deaktiviert. Du kannst sie in der <link>config.json reaktivieren</link>.",
|
||||
"analytics.system.textPosts": "Nur-Text Beiträge",
|
||||
"analytics.system.title": "Systemstatistiken",
|
||||
"analytics.system.totalBotPosts": "Anzahl der Nachrichten von Bots",
|
||||
"analytics.system.totalChannels": "Kanäle Gesamt",
|
||||
"analytics.system.totalCommands": "Befehle Gesamt",
|
||||
"analytics.system.totalFilePosts": "Beiträge mit Dateien",
|
||||
"analytics.system.totalHashtagPosts": "Beiträge mit Hashtags",
|
||||
"analytics.system.totalIncomingWebhooks": "Eingehende Webhooks",
|
||||
"analytics.system.totalMasterDbConnections": "Master DB Verbindungen",
|
||||
"analytics.system.totalOutgoingWebhooks": "Ausgehende Webhooks",
|
||||
@ -3282,6 +3282,7 @@
|
||||
"channel_info_rhs.menu.members": "Mitglieder",
|
||||
"channel_info_rhs.menu.notification_preferences": "Benachrichtigungseinstellungen",
|
||||
"channel_info_rhs.menu.pinned": "Angeheftete Nachrichten",
|
||||
"channel_info_rhs.menu.title": "Aktionen Kanalinfo",
|
||||
"channel_info_rhs.top_buttons.add_people": "Benutzer hinzufügen",
|
||||
"channel_info_rhs.top_buttons.add_people.tooltip": "Teammitglieder zu diesem Kanal hinzufügen",
|
||||
"channel_info_rhs.top_buttons.copied": "Kopiert",
|
||||
@ -4970,6 +4971,7 @@
|
||||
"search_hint.enter_to_search": "Drücke Enter zum Suchen",
|
||||
"search_hint.enter_to_select": "Drücke Enter, um auszuwählen",
|
||||
"search_hint.filter": "Filtere deine Suche mit:",
|
||||
"search_hint.reset_filters": "Deine Filter wurden zurückgesetzt, weil du ein anderes Team gewählt hast",
|
||||
"search_item.channelArchived": "Archiviert",
|
||||
"search_item.direct": "Direktnachricht (mit {username})",
|
||||
"search_item.file_tag.direct_message": "Direktnachricht",
|
||||
@ -4985,6 +4987,9 @@
|
||||
"search_list_option.on": "Nachrichten am",
|
||||
"search_list_option.phrases": "Nachrichten mit Phrasen",
|
||||
"search_results.channel-files-header": "Aktuellste Dateien",
|
||||
"search_teams_selector.all_teams": "Alle Teams",
|
||||
"search_teams_selector.search_teams": "Teams suchen",
|
||||
"search_teams_selector.your_teams": "Deine Teams",
|
||||
"sectionNotice.dismiss": "Mitteilung entfernen",
|
||||
"select_team.icon": "\"Team auswählen\"-Symbol",
|
||||
"select_team.join.icon": "\"Team beitreten\"-Symbol",
|
||||
|
@ -246,6 +246,7 @@
|
||||
"add_teams_to_scheme.confirmation.message": "This team is already selected in another team scheme, are you sure you want to move it to this team scheme?",
|
||||
"add_teams_to_scheme.confirmation.title": "Team Override Scheme Change?",
|
||||
"add_teams_to_scheme.modalTitle": "Add Teams to Team Selection List",
|
||||
"add_teams_to_scheme.select_team.label": "Select team {label}",
|
||||
"add_user_to_channel_modal.add": "Add",
|
||||
"add_user_to_channel_modal.cancel": "Cancel",
|
||||
"add_user_to_channel_modal.help": "Type to find a channel. Use ↑↓ to browse, ↵ to select, ESC to dismiss.",
|
||||
@ -482,7 +483,7 @@
|
||||
"admin.channel_settings.channel_details.archiveChannel": "Archive Channel",
|
||||
"admin.channel_settings.channel_details.isDefaultDescr": "This default channel cannot be converted into a private channel.",
|
||||
"admin.channel_settings.channel_details.isPublic": "Public channel or private channel",
|
||||
"admin.channel_settings.channel_details.isPublicDescr": "If `public` the channel is discoverable and any user can join, or if `private` invitations are required. Toggle to convert public channels to private. When Group Sync is enabled, private channels cannot be converted to public.",
|
||||
"admin.channel_settings.channel_details.isPublicDescr": "Select Public for a channel any user can find and join. {br}Select Private to require channel invitations to join. {br}Use this switch to change this channel from public to private or from private to public.",
|
||||
"admin.channel_settings.channel_details.syncGroupMembers": "Sync Group Members",
|
||||
"admin.channel_settings.channel_details.syncGroupMembersDescr": "When enabled, adding and removing users from groups will add or remove them from this channel. The only way of inviting members to this channel is by adding the groups they belong to. <link>Learn More</link>",
|
||||
"admin.channel_settings.channel_details.unarchiveChannel": "Unarchive Channel",
|
||||
@ -1494,6 +1495,7 @@
|
||||
"admin.logs.showErrors": "Show last {n} errors",
|
||||
"admin.logs.title": "Server Logs",
|
||||
"admin.manage_roles.additionalRoles": "Select additional permissions for the account. <link>Read more about roles and permissions</link>.",
|
||||
"admin.manage_roles.additionalRoles_warning": "<strong>Note:</strong><span>The permissions granted above apply to the account as a whole, regardless of whether it is authenticated using a session cookie or a personal access token. For example, selecting post:all will allow the account to post to channels it is not a member of, even without using a personal access token.</span>",
|
||||
"admin.manage_roles.allowUserAccessTokens": "Allow this account to generate <link>personal access tokens</link>.",
|
||||
"admin.manage_roles.allowUserAccessTokensDesc": "Removing this permission doesn't delete existing tokens. To delete them, go to the user's Manage Tokens menu.",
|
||||
"admin.manage_roles.botAdditionalRoles": "Select additional permissions for the account. <link>Read more about roles and permissions</link>.",
|
||||
@ -1594,8 +1596,10 @@
|
||||
"admin.password.preview": "Error message preview",
|
||||
"admin.password.symbol": "At least one symbol (e.g. '~!@#$%^&*()')",
|
||||
"admin.password.uppercase": "At least one uppercase letter",
|
||||
"admin.permissions.group.convert_private_channel_to_public.description": "Convert private channels to public",
|
||||
"admin.permissions.group.convert_private_channel_to_public.name": "Convert to public",
|
||||
"admin.permissions.group.convert_public_channel_to_private.description": "Convert public channels to private",
|
||||
"admin.permissions.group.convert_public_channel_to_private.name": "Convert Channels",
|
||||
"admin.permissions.group.convert_public_channel_to_private.name": "Convert to private",
|
||||
"admin.permissions.group.custom_groups.description": "Create, edit, delete and manage the members of custom groups.",
|
||||
"admin.permissions.group.custom_groups.name": "Custom Groups",
|
||||
"admin.permissions.group.delete_posts.description": "Delete own and other user's posts.",
|
||||
@ -1655,9 +1659,9 @@
|
||||
"admin.permissions.permission.assign_system_admin_role.description": "Assign system admin role",
|
||||
"admin.permissions.permission.assign_system_admin_role.name": "Assign system admin role",
|
||||
"admin.permissions.permission.convert_private_channel_to_public.description": "Convert private channels to public",
|
||||
"admin.permissions.permission.convert_private_channel_to_public.name": "Convert Channels",
|
||||
"admin.permissions.permission.convert_private_channel_to_public.name": "Convert to public",
|
||||
"admin.permissions.permission.convert_public_channel_to_private.description": "Convert public channels to private",
|
||||
"admin.permissions.permission.convert_public_channel_to_private.name": "Convert Channels",
|
||||
"admin.permissions.permission.convert_public_channel_to_private.name": "Convert to private",
|
||||
"admin.permissions.permission.create_custom_group.description": "Create custom groups.",
|
||||
"admin.permissions.permission.create_custom_group.name": "Create",
|
||||
"admin.permissions.permission.create_direct_channel.description": "Create direct channel",
|
||||
@ -1883,7 +1887,8 @@
|
||||
"admin.permissions.sysconsole_section_user_management_channels.name": "Channels",
|
||||
"admin.permissions.sysconsole_section_user_management_groups.name": "Groups",
|
||||
"admin.permissions.sysconsole_section_user_management_permissions.name": "Permissions",
|
||||
"admin.permissions.sysconsole_section_user_management_system_roles.name": "System Roles",
|
||||
"admin.permissions.sysconsole_section_user_management_system_roles.description": "Setting 'No Access' restricts the System Console interface only. The underlying API endpoints are accessible to all users in a read-only state for basic product functionality.",
|
||||
"admin.permissions.sysconsole_section_user_management_system_roles.name": "Delegated Granular Administration",
|
||||
"admin.permissions.sysconsole_section_user_management_teams.name": "Teams",
|
||||
"admin.permissions.sysconsole_section_user_management_users.description": "Cannot reset admin passwords",
|
||||
"admin.permissions.sysconsole_section_user_management_users.name": "Users",
|
||||
@ -2486,6 +2491,7 @@
|
||||
"admin.sidebar.smtp": "SMTP",
|
||||
"admin.sidebar.subscription": "Subscription",
|
||||
"admin.sidebar.systemRoles": "Delegated Granular Administration",
|
||||
"admin.sidebar.system_properties": "System Properties",
|
||||
"admin.sidebar.teamStatistics": "Team Statistics",
|
||||
"admin.sidebar.teams": "Teams",
|
||||
"admin.sidebar.userManagement": "User Management",
|
||||
@ -2562,6 +2568,23 @@
|
||||
"admin.systemUserDetail.teamList.teamType.groupSync": "Group Sync",
|
||||
"admin.systemUserDetail.teamList.teamType.inviteOnly": "Invite Only",
|
||||
"admin.systemUserDetail.title": "User Configuration",
|
||||
"admin.system_properties.confirm.delete.button": "Delete",
|
||||
"admin.system_properties.confirm.delete.text": "Deleting this property will remove all associated user-defined values.",
|
||||
"admin.system_properties.confirm.delete.title": "Delete {name} property",
|
||||
"admin.system_properties.details.saving_changes": "Saving configuration…",
|
||||
"admin.system_properties.details.saving_changes_error": "An error occurred while saving the configuration",
|
||||
"admin.system_properties.user_properties.add_property": "Add property",
|
||||
"admin.system_properties.user_properties.subtitle": "Customise the properties to show in user profiles",
|
||||
"admin.system_properties.user_properties.table.actions": "Actions",
|
||||
"admin.system_properties.user_properties.table.actions.delete": "Delete",
|
||||
"admin.system_properties.user_properties.table.property": "Property",
|
||||
"admin.system_properties.user_properties.table.property_name.input.name": "Property Name",
|
||||
"admin.system_properties.user_properties.table.type": "Type",
|
||||
"admin.system_properties.user_properties.table.type.text": "Text",
|
||||
"admin.system_properties.user_properties.table.validation.name_required": "Please enter a property name.",
|
||||
"admin.system_properties.user_properties.table.validation.name_taken": "Property name already taken.",
|
||||
"admin.system_properties.user_properties.table.validation.name_unique": "Property names must be unique.",
|
||||
"admin.system_properties.user_properties.title": "User Properties",
|
||||
"admin.system_roles_feature_discovery.copy": "Assign customisable admin roles to give designated users read and/or write access to select sections of System Console.",
|
||||
"admin.system_roles_feature_discovery.title": "Provide controlled access to the System Console with Mattermost Enterprise",
|
||||
"admin.system_users.column_toggler.dropdownAriaLabel": "Columns visibility menu",
|
||||
@ -2885,13 +2908,10 @@
|
||||
"analytics.system.publicChannels": "Public Channels",
|
||||
"analytics.system.seatsPurchased": "Licensed Seats",
|
||||
"analytics.system.skippedIntensiveQueries": "To maximise performance, some statistics are disabled. You can <link>re-enable them in config.json</link>.",
|
||||
"analytics.system.textPosts": "Posts with Text-only",
|
||||
"analytics.system.title": "System Statistics",
|
||||
"analytics.system.totalBotPosts": "Total Posts from Bots",
|
||||
"analytics.system.totalChannels": "Total Channels",
|
||||
"analytics.system.totalCommands": "Total Commands",
|
||||
"analytics.system.totalFilePosts": "Posts with Files",
|
||||
"analytics.system.totalHashtagPosts": "Posts with Hashtags",
|
||||
"analytics.system.totalIncomingWebhooks": "Incoming Webhooks",
|
||||
"analytics.system.totalMasterDbConnections": "Master DB Conns",
|
||||
"analytics.system.totalOutgoingWebhooks": "Outgoing Webhooks",
|
||||
@ -3211,9 +3231,11 @@
|
||||
"channel_header.closeChannelInfo": "Close Info",
|
||||
"channel_header.convert": "Convert to Private Channel",
|
||||
"channel_header.delete": "Archive Channel",
|
||||
"channel_header.directchannel": "{displayName} (you) Channel Menu",
|
||||
"channel_header.directchannel.you": "{displayname} (you) ",
|
||||
"channel_header.flagged": "Saved messages",
|
||||
"channel_header.groupMessageHasGuests": "This group message has guests",
|
||||
"channel_header.headerText.addNewButton": "Add a channel header",
|
||||
"channel_header.lastActive": "Last online {timestamp}",
|
||||
"channel_header.lastOnline": "Last online {timestamp}",
|
||||
"channel_header.leave": "Leave Channel",
|
||||
@ -3222,6 +3244,7 @@
|
||||
"channel_header.mute": "Mute Channel",
|
||||
"channel_header.muteConversation": "Mute Conversation",
|
||||
"channel_header.openChannelInfo": "View Info",
|
||||
"channel_header.otherchannel": "{displayName} Channel Menu",
|
||||
"channel_header.pinnedPosts": "Pinned messages",
|
||||
"channel_header.recentMentions": "Recent mentions",
|
||||
"channel_header.rename": "Rename Channel",
|
||||
@ -3259,6 +3282,7 @@
|
||||
"channel_info_rhs.menu.members": "Members",
|
||||
"channel_info_rhs.menu.notification_preferences": "Notification Preferences",
|
||||
"channel_info_rhs.menu.pinned": "Pinned messages",
|
||||
"channel_info_rhs.menu.title": "Channel Info Actions",
|
||||
"channel_info_rhs.top_buttons.add_people": "Add People",
|
||||
"channel_info_rhs.top_buttons.add_people.tooltip": "Add team members to this channel",
|
||||
"channel_info_rhs.top_buttons.copied": "Copied",
|
||||
@ -3683,6 +3707,8 @@
|
||||
"email_verify.return": "Return to log in",
|
||||
"email_verify.sending": "Sending email…",
|
||||
"email_verify.sent": "Verification email sent",
|
||||
"emoji_gif_picker.dialog.emojis": "Emoji Picker",
|
||||
"emoji_gif_picker.dialog.gifs": "GIF Picker",
|
||||
"emoji_gif_picker.tabs.emojis": "Emojis",
|
||||
"emoji_gif_picker.tabs.gifs": "GIFs",
|
||||
"emoji_list.actions": "Actions",
|
||||
@ -3965,6 +3991,7 @@
|
||||
"globalThreads.searchGuidance.title": "That’s the end of the list.",
|
||||
"globalThreads.sidebarLink": "Threads",
|
||||
"globalThreads.threadList.noUnreadThreads": "No unread threads",
|
||||
"globalThreads.threadList.noUnreadThreads.subtitle": "You're all caught up",
|
||||
"globalThreads.threadPane.unreadMessageLink": "You have {numUnread, plural, =0 {no unread threads} =1 {<link>{numUnread} thread</link>} other {<link>{numUnread} threads</link>}} {numUnread, plural, =0 {} other {with unread messages}}",
|
||||
"globalThreads.threadPane.unselectedTitle": "{numUnread, plural, =0 {Looks like you’re all caught up} other {Catch up on your threads}}",
|
||||
"globalThreads.title": "{prefix}Threads - {displayName} {siteName}",
|
||||
@ -4328,6 +4355,7 @@
|
||||
"mobile.set_status.dnd.icon": "Do Not Disturb Icon",
|
||||
"mobile.set_status.offline.icon": "Offline Icon",
|
||||
"mobile.set_status.online.icon": "Online Icon",
|
||||
"modal.header_close": "Close",
|
||||
"modal.manual_status.ask": "Do not ask me again",
|
||||
"modal.manual_status.auto_responder.message_away": "Would you like to switch your status to 'Away' and disable automatic replies?",
|
||||
"modal.manual_status.auto_responder.message_dnd": "Would you like to switch your status to 'Do Not Disturb' and disable automatic replies?",
|
||||
@ -4376,6 +4404,7 @@
|
||||
"more_channels.view": "View",
|
||||
"more_direct_channels.directchannel.deactivated": "{displayname} - Deactivated",
|
||||
"more_direct_channels.directchannel.you": "{displayname} (you)",
|
||||
"more_direct_channels.new_convo_add.label": "Add option {label}",
|
||||
"more_direct_channels.new_convo_note": "This will start a new conversation. If you're adding a lot of people, consider creating a private channel instead.",
|
||||
"more_direct_channels.new_convo_note.full": "You've reached the maximum number of people for this conversation. Consider creating a private channel instead.",
|
||||
"more_direct_channels.title": "Direct Messages",
|
||||
@ -4515,6 +4544,7 @@
|
||||
"onboardingTask.checklist.main_subtitle": "Let's get up and running.",
|
||||
"onboardingTask.checklist.no_thanks": "No thanks",
|
||||
"onboardingTask.checklist.start_enterprise_now": "Start your free Enterprise trial now!",
|
||||
"onboardingTask.checklist.start_onboarding_process": "Start the onboarding process.",
|
||||
"onboardingTask.checklist.task_complete_your_profile": "Complete your profile",
|
||||
"onboardingTask.checklist.task_download_mm_apps": "Download the Desktop and Mobile Apps",
|
||||
"onboardingTask.checklist.task_invite_team_members": "Invite team members to the workspace",
|
||||
@ -4627,6 +4657,9 @@
|
||||
"post.ariaLabel.replyMessage": "At {time} {date}, {authorName} replied, {message}",
|
||||
"post.reminder.acknowledgement": "You will be reminded at {reminderTime}, {reminderDate} about this message from {username}: {permaLink}",
|
||||
"post.reminder.systemBot": "Hi there, here's your reminder about this message from {username}: {permaLink}",
|
||||
"post.renderError.message": "An error occurred while rendering this post.",
|
||||
"post.renderError.retry": "Retry",
|
||||
"post.renderError.retryLabel": "Retry rendering this post",
|
||||
"post_body.check_for_out_of_channel_groups_mentions.message": "did not get notified by this mention because they are not in the channel. They cannot be added to the channel because they are not a member of the linked groups. To add them to this channel, they must be added to the linked groups.",
|
||||
"post_body.check_for_out_of_channel_mentions.link.and": " and ",
|
||||
"post_body.check_for_out_of_channel_mentions.link.private": "add them to this private channel",
|
||||
@ -4783,6 +4816,8 @@
|
||||
"pricing_modal.title": "Select a plan",
|
||||
"pricing_modal.wantToTry": "Want to try? ",
|
||||
"pricing_modal.wantToUpgrade": "Want to upgrade? ",
|
||||
"profile_popover.aria_label.with_username": "{userName}'s profile popover",
|
||||
"profile_popover.aria_label.without_username": "profile popover",
|
||||
"promote_to_user_modal.desc": "This action promotes the guest {username} to a member. It will allow the user to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to promote guest {username} to member?",
|
||||
"promote_to_user_modal.promote": "Promote",
|
||||
"promote_to_user_modal.title": "Promote guest {username} to member",
|
||||
@ -4936,6 +4971,7 @@
|
||||
"search_hint.enter_to_search": "Press Enter to search",
|
||||
"search_hint.enter_to_select": "Press Enter to select",
|
||||
"search_hint.filter": "Filter your search with:",
|
||||
"search_hint.reset_filters": "Your filters were reset because you chose a different team",
|
||||
"search_item.channelArchived": "Archived",
|
||||
"search_item.direct": "Direct Message (with {username})",
|
||||
"search_item.file_tag.direct_message": "Direct Message",
|
||||
@ -4951,6 +4987,9 @@
|
||||
"search_list_option.on": "Messages on a date",
|
||||
"search_list_option.phrases": "Messages with phrases",
|
||||
"search_results.channel-files-header": "Recent files",
|
||||
"search_teams_selector.all_teams": "All Teams",
|
||||
"search_teams_selector.search_teams": "Search teams",
|
||||
"search_teams_selector.your_teams": "Your teams",
|
||||
"sectionNotice.dismiss": "Dismiss notice",
|
||||
"select_team.icon": "Select Team Icon",
|
||||
"select_team.join.icon": "Join Team Icon",
|
||||
@ -5119,6 +5158,14 @@
|
||||
"sidebar.types.favorites": "FAVOURITES",
|
||||
"sidebar.types.unreads": "UNREADS",
|
||||
"sidebar.unreads": "More unreads",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.browseChannelsMenuItem.primaryLabel": "Browse channels",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.createCategoryMenuItem.primaryLabel": "Create new category",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.createNewChannelMenuItem.primaryLabel": "Create new channel",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.createUserGroupMenuItem.primaryLabel": "Create new user group",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.invitePeopleMenuItem.primaryLabel": "Invite people",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.invitePeopleMenuItem.secondaryLabel": "Add people to the team",
|
||||
"sidebarLeft.browserOrCreateChannelMenu.openDirectMessageMenuItem.primaryLabel": "Open a direct message",
|
||||
"sidebarLeft.browserOrCreateChannelMenuButton.label": "Browse or create channels",
|
||||
"sidebar_left.addChannelsCta": "Add channels",
|
||||
"sidebar_left.add_channel_cta_dropdown.dropdownAriaLabel": "Add Channel Dropdown",
|
||||
"sidebar_left.add_channel_dropdown.browseChannels": "Browse channels",
|
||||
@ -5145,6 +5192,7 @@
|
||||
"sidebar_left.sidebar_category_menu.viewCategory": "Mark category as read",
|
||||
"sidebar_left.sidebar_channel.selectedCount": "{count} selected",
|
||||
"sidebar_left.sidebar_channel_menu.addMembers": "Add Members",
|
||||
"sidebar_left.sidebar_channel_menu.bookmarks": "Bookmarks Bar",
|
||||
"sidebar_left.sidebar_channel_menu.channels": "Channels",
|
||||
"sidebar_left.sidebar_channel_menu.copyLink": "Copy Link",
|
||||
"sidebar_left.sidebar_channel_menu.dropdownAriaLabel": "Edit channel menu",
|
||||
@ -5533,6 +5581,7 @@
|
||||
"user.settings.display.theme.title": "Theme",
|
||||
"user.settings.display.timezone": "Timezone",
|
||||
"user.settings.display.title": "Display Settings",
|
||||
"user.settings.general.attributeExtra": "This will be shown in your profile popover.",
|
||||
"user.settings.general.close": "Close",
|
||||
"user.settings.general.confirmEmail": "Confirm Email",
|
||||
"user.settings.general.currentEmail": "Current Email",
|
||||
@ -5547,6 +5596,7 @@
|
||||
"user.settings.general.emailOffice365CantUpdate": "Login is handled through Entra ID and can't be updated. The email address used for notifications is {email}.",
|
||||
"user.settings.general.emailOpenIdCantUpdate": "Login occurs through OpenID Connect. Email cannot be updated. Email address used for notifications is {email}.",
|
||||
"user.settings.general.emailSamlCantUpdate": "Login occurs through SAML. Email cannot be updated. Email address used for notifications is {email}.",
|
||||
"user.settings.general.emptyAttribute": "Click 'Edit' to add your custom attribute",
|
||||
"user.settings.general.emptyName": "Click 'Edit' to add your full name",
|
||||
"user.settings.general.emptyNickname": "Click 'Edit' to add a nickname",
|
||||
"user.settings.general.emptyPassword": "Please enter your current password.",
|
||||
@ -5563,6 +5613,7 @@
|
||||
"user.settings.general.loginLdap": "Login done through AD/LDAP ({email})",
|
||||
"user.settings.general.loginOffice365": "Login handled by Entra ID ({email})",
|
||||
"user.settings.general.loginSaml": "Login done through SAML ({email})",
|
||||
"user.settings.general.mobile.emptyAttribute": "Click to add your custom attribute",
|
||||
"user.settings.general.mobile.emptyName": "Click to add your full name",
|
||||
"user.settings.general.mobile.emptyNickname": "Click to add a nickname",
|
||||
"user.settings.general.mobile.emptyPosition": "Click to add your job title / position",
|
||||
@ -5822,6 +5873,38 @@
|
||||
"user.settings.tokens.tokenId": "Token ID: ",
|
||||
"user.settings.tokens.tokenLoading": "Loading...",
|
||||
"user.settings.tokens.userAccessTokensNone": "No personal access tokens.",
|
||||
"userAccountMenu.awayMenuItem.label": "Away",
|
||||
"userAccountMenu.dndMenuItem.primaryLabel": "Do not disturb",
|
||||
"userAccountMenu.dndMenuItem.secondaryLabel": "Disables all notifications",
|
||||
"userAccountMenu.dndMenuItem.secondaryLabel.untilLaterSomeTime": "Until {time}",
|
||||
"userAccountMenu.dndMenuItem.secondaryLabel.untilTodaySomeTime": "Until {time}",
|
||||
"userAccountMenu.dndMenuItem.secondaryLabel.untilTomorrowSomeTime": "Until tomorrow {time}",
|
||||
"userAccountMenu.dndSubMenu.title": "Clear after:",
|
||||
"userAccountMenu.dndSubMenuItem.1Hour": "1 hour",
|
||||
"userAccountMenu.dndSubMenuItem.2Hours": "2 hours",
|
||||
"userAccountMenu.dndSubMenuItem.30Minutes": "30 minutes",
|
||||
"userAccountMenu.dndSubMenuItem.custom": "Choose date and time",
|
||||
"userAccountMenu.dndSubMenuItem.doNotClear": "Don't clear",
|
||||
"userAccountMenu.dndSubMenuItem.tomorrow": "Tomorrow",
|
||||
"userAccountMenu.dndSubMenuItem.tomorrowsDateTime": "{shortDay}, {shortTime}",
|
||||
"userAccountMenu.logoutMenuItem.label": "Log out",
|
||||
"userAccountMenu.menuButton.ariaDescription.away": "Status is 'Away'.",
|
||||
"userAccountMenu.menuButton.ariaDescription.dnd": "Status is 'Do not disturb'.",
|
||||
"userAccountMenu.menuButton.ariaDescription.offline": "Status is 'Offline'.",
|
||||
"userAccountMenu.menuButton.ariaDescription.online": "Status is 'Online'.",
|
||||
"userAccountMenu.menuButton.ariaDescription.ooo": "Status is 'Out of office'.",
|
||||
"userAccountMenu.menuButton.ariaLabel": "User's account menu",
|
||||
"userAccountMenu.offlineMenuItem.label": "Offline",
|
||||
"userAccountMenu.onlineMenuItem.label": "Online",
|
||||
"userAccountMenu.oooMenuItem.primaryLabel": "Out of office",
|
||||
"userAccountMenu.oooMenuItem.secondaryLabel": "Automatic replies are enabled",
|
||||
"userAccountMenu.profileMenuItem.label": "Profile",
|
||||
"userAccountMenu.setCustomStatusMenuItem.clearTooltip": "Clear custom status",
|
||||
"userAccountMenu.setCustomStatusMenuItem.hasStatusWithExpiryAndNoText.ariaDescription": "Status expires at {time}. Set a custom status.",
|
||||
"userAccountMenu.setCustomStatusMenuItem.hasStatusWithTextAndExpiry.ariaDescription": "Status is '\\{text}'\\ and expires at {time}. Set a custom status.",
|
||||
"userAccountMenu.setCustomStatusMenuItem.hasStatusWithTextAndNoExpiry.ariaDescription": "Status is '\\{text}'\\. Set a custom status.",
|
||||
"userAccountMenu.setCustomStatusMenuItem.noStatusSet": "Set custom status",
|
||||
"userAccountMenu.setCustomStatusMenuItem.noStatusTextSet": "Set custom status text",
|
||||
"userGuideHelp.askTheCommunity": "Ask the community",
|
||||
"userGuideHelp.helpResources": "Help resources",
|
||||
"userGuideHelp.keyboardShortcuts": "Keyboard shortcuts",
|
||||
|
@ -656,6 +656,9 @@
|
||||
"admin.customization.announcement.enableBannerTitle": "Enable System-wide Notifications:",
|
||||
"admin.customization.appDownloadLinkDesc": "Add a link to a download page for the Mattermost apps. When a link is present, an option to \"Download Mattermost Apps\" will be added in the Product Menu so users can find the download page. Leave this field blank to hide the option from the Product Menu.",
|
||||
"admin.customization.appDownloadLinkTitle": "Mattermost Apps Download Page Link:",
|
||||
"admin.customization.clientSideUserIds": "Client side user ids:",
|
||||
"admin.customization.clientSideUserIdsDesc": "Set the user ids you want to track for client side metrics. Separate values with a comma.",
|
||||
"admin.customization.clientSideUserIdsPlaceholder": "E.g.: \"userid1,userid2\"",
|
||||
"admin.customization.customUrlSchemes": "Custom URL Schemes:",
|
||||
"admin.customization.customUrlSchemesDesc": "Allows message text to link if it begins with any of the comma-separated URL schemes listed. By default, the following schemes will create links: \"http\", \"https\", \"ftp\", \"tel\", and \"mailto\".",
|
||||
"admin.customization.customUrlSchemesPlaceholder": "E.g.: \"git,smtp\"",
|
||||
@ -4934,6 +4937,7 @@
|
||||
"scheduled_posts.row_title_channel.placeholder": "In: {icon} No Destination",
|
||||
"scheduled_posts.row_title_thread.placeholder": "Thread to: {icon} No Destination",
|
||||
"scheduled_posts.row_title_thread.placeholder_tooltip": "The channel either doesn’t exist or you do not have access to it.",
|
||||
"scheduledPosts.title": "{prefix}Scheduled - {displayName} {siteName}",
|
||||
"search_bar.channels": "Channels",
|
||||
"search_bar.clear": "Clear",
|
||||
"search_bar.file_types": "File types",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user