1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-03-26 20:53:55 +02:00

Merge branch 'main' into MM-46274_module-federation-poc

This commit is contained in:
Harrison Healey 2022-09-07 15:03:44 -04:00
commit 60aef8c760
67 changed files with 11003 additions and 6161 deletions

View File

@ -45,7 +45,8 @@ const manifestStr = `
"type": "bool",
"help_text": "This allows board editors to share boards that can be accessed by anyone with the link.",
"placeholder": "",
"default": false
"default": false,
"hosting": ""
}
]
}

View File

@ -132,10 +132,11 @@
position: absolute;
left: 13px;
font-size: 18px;
top: 13px;
width: 20px;
height: 20px;
height: 100%;
opacity: 0.48;
display: flex;
align-items: center;
}
.searchQuery {

View File

@ -56,6 +56,12 @@ func (a *API) handleNotifyAdminUpgrade(w http.ResponseWriter, r *http.Request) {
// ---
// produces:
// - application/json
// parameters:
// - name: teamID
// in: path
// description: Team ID
// required: true
// type: string
// security:
// - BearerAuth: []
// responses:

View File

@ -267,7 +267,7 @@ func (a *API) handleSearchLinkableBoards(w http.ResponseWriter, r *http.Request)
}
func (a *API) handleSearchAllBoards(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /boards/search searchBoards
// swagger:operation GET /boards/search searchAllBoards
//
// Returns the boards that match with a search term
//

View File

@ -22,7 +22,7 @@ func (a *API) registerUsersRoutes(r *mux.Router) {
}
func (a *API) handleGetUsersList(w http.ResponseWriter, r *http.Request) {
// swagger:operation POST /users getUser
// swagger:operation POST /users getUsersList
//
// Returns a user[]
//

View File

@ -304,14 +304,19 @@ func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*m
}
func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*model.Board, error) {
var oldMembers []*model.BoardMember
var oldChannelID string
if patch.ChannelID != nil && *patch.ChannelID == "" {
var err error
oldMembers, err = a.GetMembersForBoard(boardID)
if err != nil {
a.logger.Error("Unable to get the board members", mlog.Err(err))
var isTemplate bool
var oldMembers []*model.BoardMember
if patch.Type != nil || patch.ChannelID != nil {
if patch.ChannelID != nil && *patch.ChannelID == "" {
var err error
oldMembers, err = a.GetMembersForBoard(boardID)
if err != nil {
a.logger.Error("Unable to get the board members", mlog.Err(err))
}
}
board, err := a.store.GetBoard(boardID)
if model.IsErrNotFound(err) {
return nil, model.NewErrNotFound(boardID)
@ -320,14 +325,17 @@ func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*mode
return nil, err
}
oldChannelID = board.ChannelID
isTemplate = board.IsTemplate
}
updatedBoard, err := a.store.PatchBoard(boardID, patch, userID)
if err != nil {
return nil, err
}
// Post message to channel if linked/unlinked
if patch.ChannelID != nil {
var username string
user, err := a.store.GetUserByID(userID)
if err != nil {
a.logger.Error("Unable to get the board updater", mlog.Err(err))
@ -338,39 +346,42 @@ func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*mode
boardLink := utils.MakeBoardLink(a.config.ServerRoot, updatedBoard.TeamID, updatedBoard.ID)
if *patch.ChannelID != "" {
// TODO: this needs translated when available on the server
message := fmt.Sprintf(linkBoardMessage, username, updatedBoard.Title, boardLink)
err := a.store.PostMessage(message, "", *patch.ChannelID)
if err != nil {
a.logger.Error("Unable to post the link message to channel", mlog.Err(err))
}
a.postChannelMessage(fmt.Sprintf(linkBoardMessage, username, updatedBoard.Title, boardLink), updatedBoard.ChannelID)
} else if *patch.ChannelID == "" {
message := fmt.Sprintf(unlinkBoardMessage, username, updatedBoard.Title, boardLink)
err := a.store.PostMessage(message, "", oldChannelID)
if err != nil {
a.logger.Error("Unable to post the link message to channel", mlog.Err(err))
}
a.postChannelMessage(fmt.Sprintf(unlinkBoardMessage, username, updatedBoard.Title, boardLink), oldChannelID)
}
}
// Broadcast Messages to affected users
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastBoardChange(updatedBoard.TeamID, updatedBoard)
if patch.ChannelID != nil && *patch.ChannelID != "" {
if patch.ChannelID != nil {
if *patch.ChannelID != "" {
members, err := a.GetMembersForBoard(updatedBoard.ID)
if err != nil {
a.logger.Error("Unable to get the board members", mlog.Err(err))
}
for _, member := range members {
if member.Synthetic {
a.wsAdapter.BroadcastMemberChange(updatedBoard.TeamID, member.BoardID, member)
}
}
} else {
for _, oldMember := range oldMembers {
if oldMember.Synthetic {
a.wsAdapter.BroadcastMemberDelete(updatedBoard.TeamID, boardID, oldMember.UserID)
}
}
}
}
if patch.Type != nil && isTemplate {
members, err := a.GetMembersForBoard(updatedBoard.ID)
if err != nil {
a.logger.Error("Unable to get the board members", mlog.Err(err))
}
for _, member := range members {
if member.Synthetic {
a.wsAdapter.BroadcastMemberChange(updatedBoard.TeamID, member.BoardID, member)
}
}
} else if patch.ChannelID != nil && *patch.ChannelID == "" {
for _, oldMember := range oldMembers {
if oldMember.Synthetic {
a.wsAdapter.BroadcastMemberDelete(updatedBoard.TeamID, boardID, oldMember.UserID)
}
}
a.broadcastTeamUsers(updatedBoard.TeamID, updatedBoard.ID, *patch.Type, members)
}
return nil
})
@ -378,6 +389,38 @@ func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*mode
return updatedBoard, nil
}
func (a *App) postChannelMessage(message, channelID string) {
err := a.store.PostMessage(message, "", channelID)
if err != nil {
a.logger.Error("Unable to post the link message to channel", mlog.Err(err))
}
}
// broadcastTeamUsers notifies the members of a team when a template changes its type
// from public to private or viceversa.
func (a *App) broadcastTeamUsers(teamID, boardID string, boardType model.BoardType, members []*model.BoardMember) {
users, err := a.GetTeamUsers(teamID, "")
if err != nil {
a.logger.Error("Unable to get the team users", mlog.Err(err))
}
for _, user := range users {
isMember := false
for _, member := range members {
if member.UserID == user.ID {
isMember = true
break
}
}
if !isMember {
if boardType == model.BoardTypePrivate {
a.wsAdapter.BroadcastMemberDelete(teamID, boardID, user.ID)
} else if boardType == model.BoardTypeOpen {
a.wsAdapter.BroadcastMemberChange(teamID, boardID, &model.BoardMember{UserID: user.ID, BoardID: boardID, SchemeViewer: true, Synthetic: true})
}
}
}
}
func (a *App) DeleteBoard(boardID, userID string) error {
board, err := a.store.GetBoard(boardID)
if model.IsErrNotFound(err) {
@ -541,8 +584,8 @@ func (a *App) DeleteBoardMember(boardID, userID string) error {
}
a.blockChangeNotifier.Enqueue(func() error {
if synteticMember, _ := a.store.GetMemberForBoard(boardID, userID); synteticMember != nil {
a.wsAdapter.BroadcastMemberChange(board.TeamID, boardID, synteticMember)
if syntheticMember, _ := a.GetMemberForBoard(boardID, userID); syntheticMember != nil {
a.wsAdapter.BroadcastMemberChange(board.TeamID, boardID, syntheticMember)
} else {
a.wsAdapter.BroadcastMemberDelete(board.TeamID, boardID, userID)
}

View File

@ -105,3 +105,262 @@ func TestAddMemberToBoard(t *testing.T) {
require.Equal(t, boardID, addedBoardMember.BoardID)
})
}
func TestPatchBoard(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case, title patch", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_1"
const teamID = "team_id_1"
patchTitle := "Patched Title"
patch := &model.BoardPatch{
Title: &patchTitle,
}
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
Title: patchTitle,
},
nil)
// for WS BroadcastBoardChange
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(1)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, patchTitle, patchedBoard.Title)
})
t.Run("patch type open, no users", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypeOpen
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "").Return([]*model.User{}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 2 times
// - for WS BroadcastBoardChange
// - for AddTeamMembers check
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
t.Run("patch type private, no users", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypePrivate
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "").Return([]*model.User{}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 2 times
// - for WS BroadcastBoardChange
// - for AddTeamMembers check
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
t.Run("patch type open, single user", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypeOpen
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "").Return([]*model.User{{ID: userID}}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 3 times
// for WS BroadcastBoardChange
// for AddTeamMembers check
// for WS BroadcastMemberChange
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(3)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
t.Run("patch type private, single user", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypePrivate
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "").Return([]*model.User{{ID: userID}}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 3 times
// for WS BroadcastBoardChange
// for AddTeamMembers check
// for WS BroadcastMemberChange
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(3)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
t.Run("patch type open, user with member", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypeOpen
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "").Return([]*model.User{{ID: userID}}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 2 times
// for WS BroadcastBoardChange
// for AddTeamMembers check
// We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).Times(2)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
t.Run("patch type private, user with member", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypePrivate
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "").Return([]*model.User{{ID: userID}}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 2 times
// for WS BroadcastBoardChange
// for AddTeamMembers check
// We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).Times(2)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
}

View File

@ -28,8 +28,9 @@ func TestPrepareOnboardingTour(t *testing.T) {
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}},
nil, nil)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(3)
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).AnyTimes()
th.Store.EXPECT().GetUsersByTeam("0", "").Return([]*model.User{}, nil)
privateWelcomeBoard := model.Board{
ID: "board_id_1",
@ -75,8 +76,9 @@ func TestCreateWelcomeBoard(t *testing.T) {
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).
Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, nil, nil)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(3)
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).AnyTimes()
th.Store.EXPECT().GetUsersByTeam("0", "").Return([]*model.User{}, nil)
privateWelcomeBoard := model.Board{
ID: "board_id_1",

View File

@ -2,25 +2,25 @@
//
// Focalboard Server
//
// Schemes: http, https
// Host: localhost
// BasePath: /api/v2
// Version: 1.0.0
// License: Custom https://github.com/mattermost/focalboard/blob/main/LICENSE.txt
// Contact: Focalboard<api@focalboard.com> https://www.focalboard.com
// Schemes: http, https
// Host: localhost
// BasePath: /api/v2
// Version: 2.0.0
// License: Custom https://github.com/mattermost/focalboard/blob/main/LICENSE.txt
// Contact: Focalboard<api@focalboard.com> https://www.focalboard.com
//
// Consumes:
// - application/json
// Consumes:
// - application/json
//
// Produces:
// - application/json
// Produces:
// - application/json
//
// securityDefinitions:
// BearerAuth:
// type: apiKey
// name: Authorization
// in: header
// description: 'Pass session token using Bearer authentication, e.g. set header "Authorization: Bearer <session token>"'
// securityDefinitions:
// BearerAuth:
// type: apiKey
// name: Authorization
// in: header
// description: 'Pass session token using Bearer authentication, e.g. set header "Authorization: Bearer <session token>"'
//
// swagger:meta
package main

View File

@ -36,6 +36,7 @@ type servicesAPI interface {
GetCloudLimits() (*mmModel.ProductLimits, error)
EnsureBot(bot *mmModel.Bot) (string, error)
CreatePost(post *mmModel.Post) (*mmModel.Post, error)
GetTeamMember(teamID string, userID string) (*mmModel.TeamMember, error)
GetPreferencesForUser(userID string) (mmModel.Preferences, error)
DeletePreferencesForUser(userID string, preferences mmModel.Preferences) error
UpdatePreferencesForUser(userID string, preferences mmModel.Preferences) error
@ -380,8 +381,8 @@ func (s *MattermostAuthLayer) SearchUsersByTeam(teamID string, searchQuery strin
boardsIDs = append(boardsIDs, board.ID)
}
query = query.
Join(s.tablePrefix + "board_members as bm ON bm.UserID = u.ID").
Where(sq.Eq{"bm.BoardId": boardsIDs})
Join(s.tablePrefix + "board_members as bm ON bm.user_id = u.ID").
Where(sq.Eq{"bm.board_id": boardsIDs})
}
rows, err := query.Query()
@ -768,6 +769,27 @@ func (s *MattermostAuthLayer) GetMemberForBoard(boardID, userID string) (*model.
Synthetic: true,
}, nil
}
if b.Type == model.BoardTypeOpen && b.IsTemplate {
_, memberErr := s.servicesAPI.GetTeamMember(b.TeamID, userID)
if memberErr != nil {
var appErr *mmModel.AppError
if errors.As(memberErr, &appErr) && appErr.StatusCode == http.StatusNotFound {
return nil, model.NewErrNotFound(userID)
}
return nil, memberErr
}
return &model.BoardMember{
BoardID: boardID,
UserID: userID,
Roles: "viewer",
SchemeAdmin: false,
SchemeEditor: false,
SchemeCommenter: false,
SchemeViewer: true,
Synthetic: true,
}, nil
}
}
if err != nil {
return nil, err
@ -860,13 +882,22 @@ func (s *MattermostAuthLayer) GetBoardsForUserAndTeam(userID, teamID string, inc
return nil, err
}
// TODO: Handle the includePublicBoards
boardIDs := []string{}
for _, m := range members {
boardIDs = append(boardIDs, m.BoardID)
}
if includePublicBoards {
var boards []*model.Board
boards, err = s.SearchBoardsForUserInTeam(teamID, "", userID)
if err != nil {
return nil, err
}
for _, b := range boards {
boardIDs = append(boardIDs, b.ID)
}
}
boards, err := s.Store.GetBoardsInTeamByIds(boardIDs, teamID)
if err != nil {
return nil, err

View File

@ -10,8 +10,6 @@ import (
"text/template"
"github.com/mattermost/morph/models"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
"github.com/mattermost/mattermost-server/v6/store/sqlstore"
@ -24,10 +22,7 @@ import (
_ "github.com/lib/pq" // postgres driver
sq "github.com/Masterminds/squirrel"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/mattermost-plugin-api/cluster"
)
//go:embed migrations
@ -74,6 +69,32 @@ func (s *SQLStore) getMigrationConnection() (*sql.DB, error) {
}
func (s *SQLStore) Migrate() error {
if s.isPlugin {
mutex, mutexErr := s.NewMutexFn("Boards_dbMutex")
if mutexErr != nil {
return fmt.Errorf("error creating database mutex: %w", mutexErr)
}
s.logger.Debug("Acquiring cluster lock for Focalboard migrations")
mutex.Lock()
defer func() {
s.logger.Debug("Releasing cluster lock for Focalboard migrations")
mutex.Unlock()
}()
}
if err := s.EnsureSchemaMigrationFormat(); err != nil {
return err
}
defer func() {
// the old schema migration table deletion happens after the
// migrations have run, to be able to recover its information
// in case there would be errors during the process.
if err := s.deleteOldSchemaMigrationTable(); err != nil {
s.logger.Error("cannot delete the old schema migration table", mlog.Err(err))
}
}()
var driver drivers.Driver
var err error
@ -181,26 +202,12 @@ func (s *SQLStore) Migrate() error {
engine.Close()
}()
var mutex *cluster.Mutex
if s.isPlugin {
var mutexErr error
mutex, mutexErr = s.NewMutexFn("Boards_dbMutex")
if mutexErr != nil {
return fmt.Errorf("error creating database mutex: %w", mutexErr)
}
s.logger.Debug("Acquiring cluster lock for Focalboard migrations")
mutex.Lock()
defer func() {
s.logger.Debug("Releasing cluster lock for Focalboard migrations")
mutex.Unlock()
}()
}
if mErr := s.migrateSchemaVersionTable(src.Migrations()); mErr != nil {
return mErr
}
return s.runMigrationSequence(engine, driver)
}
// runMigrationSequence executes all the migrations in order, both
// plain SQL and data migrations.
func (s *SQLStore) runMigrationSequence(engine *morph.Morph, driver drivers.Driver) error {
if mErr := s.ensureMigrationsAppliedUpToVersion(engine, driver, uniqueIDsMigrationRequiredVersion); mErr != nil {
return mErr
}
@ -214,11 +221,11 @@ func (s *SQLStore) Migrate() error {
}
if mErr := s.RunTeamLessBoardsMigration(); mErr != nil {
return mErr
return fmt.Errorf("error running teamless boards migration: %w", mErr)
}
if mErr := s.RunDeletedMembershipBoardsMigration(); mErr != nil {
return mErr
return fmt.Errorf("error running deleted membership boards migration: %w", mErr)
}
if mErr := s.ensureMigrationsAppliedUpToVersion(engine, driver, categoriesUUIDIDMigrationRequiredVersion); mErr != nil {
@ -229,10 +236,6 @@ func (s *SQLStore) Migrate() error {
return fmt.Errorf("error running categoryID migration: %w", mErr)
}
if mErr := s.deleteOldSchemaMigrationTable(); mErr != nil {
return mErr
}
appliedMigrations, err := driver.AppliedMigrations()
if err != nil {
return err
@ -244,218 +247,6 @@ func (s *SQLStore) Migrate() error {
return engine.ApplyAll()
}
// migrateSchemaVersionTable converts the schema version table from
// the old format used by go-migrate to the new format used by
// gomorph.
// When running the Focalboard with go-migrate's schema version table
// existing in the database, gomorph is unable to make sense of it as it's
// not in the format required by gomorph.
func (s *SQLStore) migrateSchemaVersionTable(migrations []*models.Migration) error {
migrationNeeded, err := s.isSchemaMigrationNeeded()
if err != nil {
return err
}
if !migrationNeeded {
return nil
}
s.logger.Info("Migrating schema migration to new format")
legacySchemaVersion, err := s.getLegacySchemaVersion()
if err != nil {
return err
}
if err := s.createTempSchemaTable(); err != nil {
return err
}
if err := s.populateTempSchemaTable(migrations, legacySchemaVersion); err != nil {
return err
}
if err := s.useNewSchemaTable(); err != nil {
return err
}
return nil
}
func (s *SQLStore) isSchemaMigrationNeeded() (bool, error) {
// Check if `dirty` column exists on schema version table.
// This column exists only for the old schema version table.
// SQLite needs a bit of a special handling
if s.dbType == model.SqliteDBType {
return s.isSchemaMigrationNeededSQLite()
}
query := s.getQueryBuilder(s.db).
Select("count(*)").
From("information_schema.COLUMNS").
Where(sq.Eq{
"TABLE_NAME": s.tablePrefix + "schema_migrations",
"COLUMN_NAME": "dirty",
})
row := query.QueryRow()
var count int
if err := row.Scan(&count); err != nil {
s.logger.Error("failed to check for columns of schema_migrations table", mlog.Err(err))
return false, err
}
return count == 1, nil
}
func (s *SQLStore) isSchemaMigrationNeededSQLite() (bool, error) {
// the way to check presence of a column is different
// for SQLite. Hence, the separate function
query := fmt.Sprintf("PRAGMA table_info(\"%sschema_migrations\");", s.tablePrefix)
rows, err := s.db.Query(query)
if err != nil {
s.logger.Error("SQLite - failed to check for columns in schema_migrations table", mlog.Err(err))
return false, err
}
defer s.CloseRows(rows)
data := [][]*string{}
for rows.Next() {
// PRAGMA returns 6 columns
row := make([]*string, 6)
err := rows.Scan(
&row[0],
&row[1],
&row[2],
&row[3],
&row[4],
&row[5],
)
if err != nil {
s.logger.Error("error scanning rows from SQLite schema_migrations table definition", mlog.Err(err))
return false, err
}
data = append(data, row)
}
nameColumnFound := false
for _, row := range data {
if len(row) >= 2 && *row[1] == "dirty" {
nameColumnFound = true
break
}
}
return nameColumnFound, nil
}
func (s *SQLStore) getLegacySchemaVersion() (uint32, error) {
query := s.getQueryBuilder(s.db).
Select("version").
From(s.tablePrefix + "schema_migrations")
row := query.QueryRow()
var version uint32
if err := row.Scan(&version); err != nil {
s.logger.Error("error fetching legacy schema version", mlog.Err(err))
s.logger.Error("getLegacySchemaVersion err " + err.Error())
return version, err
}
return version, nil
}
func (s *SQLStore) createTempSchemaTable() error {
// squirrel doesn't support DDL query in query builder
// so, we need to use a plain old string
query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (Version bigint NOT NULL, Name varchar(64) NOT NULL, PRIMARY KEY (Version))", s.tablePrefix+tempSchemaMigrationTableName)
if _, err := s.db.Exec(query); err != nil {
s.logger.Error("failed to create temporary schema migration table", mlog.Err(err))
s.logger.Error("createTempSchemaTable error " + err.Error())
return err
}
return nil
}
func (s *SQLStore) populateTempSchemaTable(migrations []*models.Migration, legacySchemaVersion uint32) error {
query := s.getQueryBuilder(s.db).
Insert(s.tablePrefix+tempSchemaMigrationTableName).
Columns("Version", "Name")
for _, migration := range migrations {
// migrations param contains both up and down variant for
// each migration. Skipping for either one (down in this case)
// to process a migration only a single time.
if migration.Direction == models.Down {
continue
}
if migration.Version > legacySchemaVersion {
break
}
query = query.Values(migration.Version, migration.Name)
}
if _, err := query.Exec(); err != nil {
s.logger.Error("failed to insert migration records into temporary schema table", mlog.Err(err))
return err
}
return nil
}
func (s *SQLStore) useNewSchemaTable() error {
// first delete the old table, then
// rename the new table to old table's name
// renaming old schema migration table. Will delete later once the migration is
// complete, just in case.
var query string
if s.dbType == model.MysqlDBType {
query = fmt.Sprintf("RENAME TABLE `%sschema_migrations` TO `%sschema_migrations_old_temp`", s.tablePrefix, s.tablePrefix)
} else {
query = fmt.Sprintf("ALTER TABLE %sschema_migrations RENAME TO %sschema_migrations_old_temp", s.tablePrefix, s.tablePrefix)
}
if _, err := s.db.Exec(query); err != nil {
s.logger.Error("failed to rename old schema migration table", mlog.Err(err))
return err
}
// renaming new temp table to old table's name
if s.dbType == model.MysqlDBType {
query = fmt.Sprintf("RENAME TABLE `%s%s` TO `%sschema_migrations`", s.tablePrefix, tempSchemaMigrationTableName, s.tablePrefix)
} else {
query = fmt.Sprintf("ALTER TABLE %s%s RENAME TO %sschema_migrations", s.tablePrefix, tempSchemaMigrationTableName, s.tablePrefix)
}
if _, err := s.db.Exec(query); err != nil {
s.logger.Error("failed to rename temp schema table", mlog.Err(err))
return err
}
return nil
}
func (s *SQLStore) deleteOldSchemaMigrationTable() error {
query := "DROP TABLE IF EXISTS " + s.tablePrefix + "schema_migrations_old_temp"
if _, err := s.db.Exec(query); err != nil {
s.logger.Error("failed to delete old temp schema migrations table", mlog.Err(err))
return err
}
return nil
}
func (s *SQLStore) ensureMigrationsAppliedUpToVersion(engine *morph.Morph, driver drivers.Driver, version int) error {
applied, err := driver.AppliedMigrations()
if err != nil {

View File

@ -2,52 +2,52 @@
{{- /* For plugin mode, we need to write into Mattermost's `Preferences` table, hence, no use of `prefix`. */ -}}
{{if .postgres}}
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'welcomePageViewed', replace((props->'focalboard_welcomePageViewed')::varchar, '"', '') FROM Users WHERE props->'focalboard_welcomePageViewed' IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace((props->'hiddenBoardIDs')::varchar, '"[', '['), ']"', ']'), '\"', '"') FROM Users WHERE props->'hiddenBoardIDs' IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'tourCategory', replace((props->'focalboard_tourCategory')::varchar, '"', '') FROM Users WHERE props->'focalboard_tourCategory' IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'onboardingTourStep', replace((props->'focalboard_onboardingTourStep')::varchar, '"', '') FROM Users WHERE props->'focalboard_onboardingTourStep' IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'onboardingTourStarted', replace((props->'focalboard_onboardingTourStarted')::varchar, '"', '') FROM Users WHERE props->'focalboard_onboardingTourStarted' IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'version72MessageCanceled', replace((props->'focalboard_version72MessageCanceled')::varchar, '"', '') FROM Users WHERE props->'focalboard_version72MessageCanceled' IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'lastWelcomeVersion', replace((props->'focalboard_lastWelcomeVersion')::varchar, '"', '') FROM Users WHERE props->'focalboard_lastWelcomeVersion' IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'welcomePageViewed', replace((Props->'focalboard_welcomePageViewed')::varchar, '"', '') FROM Users WHERE Props->'focalboard_welcomePageViewed' IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace((Props->'hiddenBoardIDs')::varchar, '"[', '['), ']"', ']'), '\"', '"') FROM Users WHERE Props->'hiddenBoardIDs' IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'tourCategory', replace((Props->'focalboard_tourCategory')::varchar, '"', '') FROM Users WHERE Props->'focalboard_tourCategory' IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStep', replace((Props->'focalboard_onboardingTourStep')::varchar, '"', '') FROM Users WHERE Props->'focalboard_onboardingTourStep' IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStarted', replace((Props->'focalboard_onboardingTourStarted')::varchar, '"', '') FROM Users WHERE Props->'focalboard_onboardingTourStarted' IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'version72MessageCanceled', replace((Props->'focalboard_version72MessageCanceled')::varchar, '"', '') FROM Users WHERE Props->'focalboard_version72MessageCanceled' IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'lastWelcomeVersion', replace((Props->'focalboard_lastWelcomeVersion')::varchar, '"', '') FROM Users WHERE Props->'focalboard_lastWelcomeVersion' IS NOT NULL;
UPDATE Users SET props = (props - 'focalboard_welcomePageViewed' - 'hiddenBoardIDs' - 'focalboard_tourCategory' - 'focalboard_onboardingTourStep' - 'focalboard_onboardingTourStarted' - 'focalboard_version72MessageCanceled' - 'focalboard_lastWelcomeVersion');
{{else}}
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'welcomePageViewed', replace(JSON_EXTRACT(props, '$.focalboard_welcomePageViewed'), '"', '') FROM Users WHERE JSON_EXTRACT(props, '$.focalboard_welcomePageViewed') IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace(JSON_EXTRACT(props, '$.hiddenBoardIDs'), '"[', '['), ']"', ']'), '\\"', '"') FROM Users WHERE JSON_EXTRACT(props, '$.hiddenBoardIDs') IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'tourCategory', replace(JSON_EXTRACT(props, '$.focalboard_tourCategory'), '"', '') FROM Users WHERE JSON_EXTRACT(props, '$.focalboard_tourCategory') IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'onboardingTourStep', replace(JSON_EXTRACT(props, '$.focalboard_onboardingTourStep'), '"', '') FROM Users WHERE JSON_EXTRACT(props, '$.focalboard_onboardingTourStep') IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'onboardingTourStarted', replace(JSON_EXTRACT(props, '$.focalboard_onboardingTourStarted'), '"', '') FROM Users WHERE JSON_EXTRACT(props, '$.focalboard_onboardingTourStarted') IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'version72MessageCanceled', replace(JSON_EXTRACT(props, '$.focalboard_version72MessageCanceled'), '"', '') FROM Users WHERE JSON_EXTRACT(props, '$.focalboard_version72MessageCanceled') IS NOT NULL;
INSERT INTO Preferences (userid, category, name, value) SELECT id, 'focalboard', 'lastWelcomeVersion', replace(JSON_EXTRACT(props, 'focalboard_lastWelcomeVersion'), '"', '') FROM Users WHERE JSON_EXTRACT(props, '$.focalboard_lastWelcomeVersion') IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'welcomePageViewed', replace(JSON_EXTRACT(Props, '$."focalboard_welcomePageViewed"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_welcomePageViewed') IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace(JSON_EXTRACT(Props, '$."hiddenBoardIDs'), '"[', '['), ']"', ']'), '\\"', '"') FROM Users WHERE JSON_EXTRACT(Props, '$.hiddenBoardIDs') IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'tourCategory', replace(JSON_EXTRACT(Props, '$."focalboard_tourCategory"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_tourCategory') IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStep', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStep"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStep') IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStarted', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStarted"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStarted') IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'version72MessageCanceled', replace(JSON_EXTRACT(Props, '$."focalboard_version72MessageCanceled"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_version72MessageCanceled') IS NOT NULL;
INSERT INTO Preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'lastWelcomeVersion', replace(JSON_EXTRACT(Props, 'focalboard_lastWelcomeVersion"'), '"', '') FROM Users WHERE JSON_EXTRACT(Props, '$.focalboard_lastWelcomeVersion') IS NOT NULL;
UPDATE Users SET props = JSON_REMOVE(props, '$.focalboard_welcomePageViewed', '$.hiddenBoardIDs', '$.focalboard_tourCategory', '$.focalboard_onboardingTourStep', '$.focalboard_onboardingTourStarted', '$.focalboard_version72MessageCanceled', '$.focalboard_lastWelcomeVersion');
UPDATE Users SET Props = JSON_REMOVE(Props, '$."focalboard_welcomePageViewed"', '$."hiddenBoardIDs"', '$."focalboard_tourCategory"', '$."focalboard_onboardingTourStep"', '$."focalboard_onboardingTourStarted"', '$."focalboard_version72MessageCanceled"', '$."focalboard_lastWelcomeVersion"');
{{end}}
{{else}}
{{- /* For personal server, we need to write to Focalboard's preferences table, hence the use of `prefix`. */ -}}
{{if .postgres}}
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'welcomePageViewed', replace((props->'focalboard_welcomePageViewed')::varchar, '"', '') from {{.prefix}}users WHERE props->'focalboard_welcomePageViewed' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace((props->'hiddenBoardIDs')::varchar, '"[', '['), ']"', ']'), '\"', '"') from {{.prefix}}users WHERE props->'hiddenBoardIDs' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'tourCategory', replace((props->'focalboard_tourCategory')::varchar, '"', '') from {{.prefix}}users WHERE props->'focalboard_tourCategory' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'onboardingTourStep', replace((props->'focalboard_onboardingTourStep')::varchar, '"', '') from {{.prefix}}users WHERE props->'focalboard_onboardingTourStep' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'onboardingTourStarted', replace((props->'focalboard_onboardingTourStarted')::varchar, '"', '') from {{.prefix}}users WHERE props->'focalboard_onboardingTourStarted' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'version72MessageCanceled', replace((props->'focalboard_version72MessageCanceled')::varchar, '"', '') from {{.prefix}}users WHERE props->'focalboard_version72MessageCanceled' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'lastWelcomeVersion', replace((props->'focalboard_lastWelcomeVersion')::varchar, '"', '') from {{.prefix}}users WHERE props->'focalboard_lastWelcomeVersion' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'welcomePageViewed', replace((Props->'focalboard_welcomePageViewed')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_welcomePageViewed' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace((Props->'hiddenBoardIDs')::varchar, '"[', '['), ']"', ']'), '\"', '"') from {{.prefix}}users WHERE Props->'hiddenBoardIDs' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'tourCategory', replace((Props->'focalboard_tourCategory')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_tourCategory' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStep', replace((Props->'focalboard_onboardingTourStep')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_onboardingTourStep' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStarted', replace((Props->'focalboard_onboardingTourStarted')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_onboardingTourStarted' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'version72MessageCanceled', replace((Props->'focalboard_version72MessageCanceled')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_version72MessageCanceled' IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'lastWelcomeVersion', replace((Props->'focalboard_lastWelcomeVersion')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_lastWelcomeVersion' IS NOT NULL;
UPDATE {{.prefix}}users SET props = (props::jsonb - 'focalboard_welcomePageViewed' - 'hiddenBoardIDs' - 'focalboard_tourCategory' - 'focalboard_onboardingTourStep' - 'focalboard_onboardingTourStarted' - 'focalboard_version72MessageCanceled' - 'focalboard_lastWelcomeVersion')::json;
{{else}}
{{- /* Surprisingly SQLite and MySQL have same JSON functions and syntax! */ -}}
{{- /* Surprisingly SQLite and MySQL have same JSON functions and syntax! */ -}}
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'welcomePageViewed', replace(JSON_EXTRACT(props, '$.focalboard_welcomePageViewed'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(props, '$.focalboard_welcomePageViewed') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace(JSON_EXTRACT(props, '$.hiddenBoardIDs'), '"[', '['), ']"', ']'), '\\"', '"') from {{.prefix}}users WHERE JSON_EXTRACT(props, '$.hiddenBoardIDs') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'tourCategory', replace(JSON_EXTRACT(props, '$.focalboard_tourCategory'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(props, '$.focalboard_tourCategory') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'onboardingTourStep', replace(JSON_EXTRACT(props, '$.focalboard_onboardingTourStep'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(props, '$.focalboard_onboardingTourStep') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'onboardingTourStarted', replace(JSON_EXTRACT(props, '$.focalboard_onboardingTourStarted'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(props, '$.focalboard_onboardingTourStarted') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'version72MessageCanceled', replace(JSON_EXTRACT(props, '$.focalboard_version72MessageCanceled'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(props, '$.focalboard_version72MessageCanceled') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (userid, category, name, value) SELECT id, 'focalboard', 'lastWelcomeVersion', replace(JSON_EXTRACT(props, 'focalboard_lastWelcomeVersion'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(props, '$.focalboard_lastWelcomeVersion') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'welcomePageViewed', replace(JSON_EXTRACT(Props, '$."focalboard_welcomePageViewed"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_welcomePageViewed') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'hiddenBoardIDs', replace(replace(replace(JSON_EXTRACT(Props, '$."hiddenBoardIDs"'), '"[', '['), ']"', ']'), '\\"', '"') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.hiddenBoardIDs') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'tourCategory', replace(JSON_EXTRACT(Props, '$."focalboard_tourCategory"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_tourCategory') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStep', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStep"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStep') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'onboardingTourStarted', replace(JSON_EXTRACT(Props, '$."focalboard_onboardingTourStarted"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_onboardingTourStarted') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'version72MessageCanceled', replace(JSON_EXTRACT(Props, '$."focalboard_version72MessageCanceled"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_version72MessageCanceled') IS NOT NULL;
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'lastWelcomeVersion', replace(JSON_EXTRACT(Props, '$."focalboard_lastWelcomeVersion"'), '"', '') from {{.prefix}}users WHERE JSON_EXTRACT(Props, '$.focalboard_lastWelcomeVersion') IS NOT NULL;
UPDATE {{.prefix}}users SET props = JSON_REMOVE(props, '$.focalboard_welcomePageViewed', '$.hiddenBoardIDs', '$.focalboard_tourCategory', '$.focalboard_onboardingTourStep', '$.focalboard_onboardingTourStarted', '$.focalboard_version72MessageCanceled', '$.focalboard_lastWelcomeVersion');
UPDATE {{.prefix}}users SET Props = JSON_REMOVE(Props, '$."focalboard_welcomePageViewed"', '$."hiddenBoardIDs"', '$."focalboard_tourCategory"', '$."focalboard_onboardingTourStep"', '$."focalboard_onboardingTourStarted"', '$."focalboard_version72MessageCanceled"', '$."focalboard_lastWelcomeVersion"');
{{end}}
{{end}}

View File

@ -13,6 +13,7 @@ import (
"github.com/mattermost/morph/drivers"
"github.com/mattermost/morph/drivers/mysql"
"github.com/mattermost/morph/drivers/postgres"
"github.com/mattermost/morph/drivers/sqlite"
embedded "github.com/mattermost/morph/sources/embedded"
"github.com/mgdelacroix/foundation"
@ -86,18 +87,22 @@ func (bm *BoardsMigrator) getDriver(migrationsTable string) (drivers.Driver, err
var driver drivers.Driver
var err error
if bm.driverName == model.PostgresDBType {
switch bm.driverName {
case model.PostgresDBType:
driver, err = postgres.WithInstance(bm.db, &postgres.Config{Config: migrationConfig})
if err != nil {
return nil, err
}
}
if bm.driverName == model.MysqlDBType {
case model.MysqlDBType:
driver, err = mysql.WithInstance(bm.db, &mysql.Config{Config: migrationConfig})
if err != nil {
return nil, err
}
case model.SqliteDBType:
driver, err = sqlite.WithInstance(bm.db, &sqlite.Config{Config: migrationConfig})
if err != nil {
return nil, err
}
}
return driver, nil
@ -204,7 +209,7 @@ func (bm *BoardsMigrator) Setup() error {
TablePrefix: tablePrefix,
Logger: mlog.CreateConsoleTestLogger(false, mlog.LvlDebug),
DB: bm.db,
IsPlugin: true,
IsPlugin: bm.withMattermostMigrations,
NewMutexFn: func(name string) (*cluster.Mutex, error) {
return nil, fmt.Errorf("not implemented")
},

View File

@ -0,0 +1,4 @@
INSERT INTO focalboard_users
(id, username, props)
VALUES
('user-id', 'johndoe', '{"focalboard_welcomePageViewed": true, "hiddenBoardIDs": ["board1", "board2"], "focalboard_tourCategory": "onboarding", "focalboard_onboardingTourStep": 1, "focalboard_onboardingTourStarted": true, "focalboard_version72MessageCanceled": true, "focalboard_lastWelcomeVersion": 7}');

View File

@ -0,0 +1,97 @@
package migrationstests
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func Test27MigrateUserPropsToPreferences(t *testing.T) {
t.Run("should correctly migrate properties on personal server and desktop", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.f.MigrateToStep(26).
ExecFile("./fixtures/test27MigrateUserPropsToPreferences.sql")
// first we check that the data was correctly loaded from the
// fixtures. We could perfectly skip this step, but as the
// failing data is in a JSON field, I preferred to leave it
// for clarity
user := struct {
ID string
Username string
Props string
}{}
err := th.f.DB().Get(&user, "SELECT id, username, props FROM focalboard_users WHERE id = 'user-id'")
require.NoError(t, err)
userProps := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(user.Props), &userProps))
require.Equal(t, "johndoe", user.Username)
require.Contains(t, userProps, "focalboard_welcomePageViewed")
require.True(t, userProps["focalboard_welcomePageViewed"].(bool))
require.Contains(t, userProps, "hiddenBoardIDs")
require.ElementsMatch(t, []string{"board1", "board2"}, userProps["hiddenBoardIDs"])
require.Contains(t, userProps, "focalboard_tourCategory")
require.Equal(t, "onboarding", userProps["focalboard_tourCategory"])
require.Contains(t, userProps, "focalboard_onboardingTourStep")
require.Equal(t, float64(1), userProps["focalboard_onboardingTourStep"])
require.Contains(t, userProps, "focalboard_onboardingTourStarted")
require.True(t, userProps["focalboard_onboardingTourStarted"].(bool))
require.Contains(t, userProps, "focalboard_version72MessageCanceled")
require.True(t, userProps["focalboard_version72MessageCanceled"].(bool))
require.Contains(t, userProps, "focalboard_lastWelcomeVersion")
require.Equal(t, float64(7), userProps["focalboard_lastWelcomeVersion"])
// we apply the migration
th.f.MigrateToStep(27)
// then we load the preferences on a new struct
userPreferences := []struct {
Name string
Value string
}{}
nErr := th.f.DB().Select(&userPreferences, "SELECT name, value FROM focalboard_preferences WHERE UserId = 'user-id'")
require.NoError(t, nErr)
// helper function to quickly get a preference value from the
// userPreferences slice
getValue := func(name string) string {
for _, userPreference := range userPreferences {
if userPreference.Name == name {
return userPreference.Value
}
}
require.FailNow(t, "could not found preference", "while searching for name %q", name)
return "this should never be reached"
}
// and we check that the values are correct
welcomePageViewedValue := getValue("welcomePageViewed")
// the checks for true or 1 make the test work for all DBs,
// that were representing the boolean values in the JSON
// struct in different ways
require.True(t, welcomePageViewedValue == "true" || welcomePageViewedValue == "1")
hiddenBoardIDsValue := getValue("hiddenBoardIDs")
require.Contains(t, hiddenBoardIDsValue, "board1")
require.Contains(t, hiddenBoardIDsValue, "board2")
require.Equal(t, "onboarding", getValue("tourCategory"))
onboardingTourStepValue := getValue("onboardingTourStep")
require.True(t, onboardingTourStepValue == "true" || onboardingTourStepValue == "1")
onboardingTourStartedValue := getValue("onboardingTourStarted")
require.True(t, onboardingTourStartedValue == "true" || onboardingTourStartedValue == "1")
version72MessageCanceledValue := getValue("version72MessageCanceled")
require.True(t, version72MessageCanceledValue == "true" || version72MessageCanceledValue == "1")
require.Equal(t, "7", getValue("lastWelcomeVersion"))
})
}

View File

@ -0,0 +1,268 @@
package sqlstore
import (
"bytes"
"fmt"
"io"
sq "github.com/Masterminds/squirrel"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
"github.com/mattermost/morph/models"
)
// EnsureSchemaMigrationFormat checks the schema migrations table
// format and, if it's not using the new shape, it migrates the old
// one's status before initializing the migrations engine.
func (s *SQLStore) EnsureSchemaMigrationFormat() error {
migrationNeeded, err := s.isSchemaMigrationNeeded()
if err != nil {
return err
}
if !migrationNeeded {
return nil
}
s.logger.Info("Migrating schema migration to new format")
legacySchemaVersion, err := s.getLegacySchemaVersion()
if err != nil {
return err
}
migrations, err := getEmbeddedMigrations()
if err != nil {
return err
}
filteredMigrations := filterMigrations(migrations, legacySchemaVersion)
if err := s.createTempSchemaTable(); err != nil {
return err
}
s.logger.Info("Populating the temporal schema table", mlog.Uint32("legacySchemaVersion", legacySchemaVersion), mlog.Int("migrations", len(filteredMigrations)))
if err := s.populateTempSchemaTable(filteredMigrations); err != nil {
return err
}
if err := s.useNewSchemaTable(); err != nil {
return err
}
return nil
}
// getEmbeddedMigrations returns a list of the embedded migrations
// using the morph migration format. The migrations do not have the
// contents set, as the goal is to obtain a list of them.
func getEmbeddedMigrations() ([]*models.Migration, error) {
assetsList, err := Assets.ReadDir("migrations")
if err != nil {
return nil, err
}
migrations := []*models.Migration{}
for _, f := range assetsList {
m, err := models.NewMigration(io.NopCloser(&bytes.Buffer{}), f.Name())
if err != nil {
return nil, err
}
if m.Direction != models.Up {
continue
}
migrations = append(migrations, m)
}
return migrations, nil
}
// filterMigrations takes the whole list of migrations parsed from the
// embedded directory and returns a filtered list that only contains
// one migration per version and those migrations that have already
// run based on the legacySchemaVersion.
func filterMigrations(migrations []*models.Migration, legacySchemaVersion uint32) []*models.Migration {
filteredMigrations := []*models.Migration{}
for _, migration := range migrations {
// we only take into account up migrations to avoid duplicates
if migration.Direction != models.Up {
continue
}
// we're only interested on registering migrations that
// already run, so we skip those above the legacy version
if migration.Version > legacySchemaVersion {
continue
}
filteredMigrations = append(filteredMigrations, migration)
}
return filteredMigrations
}
func (s *SQLStore) isSchemaMigrationNeeded() (bool, error) {
// Check if `dirty` column exists on schema version table.
// This column exists only for the old schema version table.
// SQLite needs a bit of a special handling
if s.dbType == model.SqliteDBType {
return s.isSchemaMigrationNeededSQLite()
}
query := s.getQueryBuilder(s.db).
Select("count(*)").
From("information_schema.COLUMNS").
Where(sq.Eq{
"TABLE_NAME": s.tablePrefix + "schema_migrations",
"COLUMN_NAME": "dirty",
})
row := query.QueryRow()
var count int
if err := row.Scan(&count); err != nil {
s.logger.Error("failed to check for columns of schema_migrations table", mlog.Err(err))
return false, err
}
return count == 1, nil
}
func (s *SQLStore) isSchemaMigrationNeededSQLite() (bool, error) {
// the way to check presence of a column is different
// for SQLite. Hence, the separate function
query := fmt.Sprintf("PRAGMA table_info(\"%sschema_migrations\");", s.tablePrefix)
rows, err := s.db.Query(query)
if err != nil {
s.logger.Error("SQLite - failed to check for columns in schema_migrations table", mlog.Err(err))
return false, err
}
defer s.CloseRows(rows)
data := [][]*string{}
for rows.Next() {
// PRAGMA returns 6 columns
row := make([]*string, 6)
err := rows.Scan(
&row[0],
&row[1],
&row[2],
&row[3],
&row[4],
&row[5],
)
if err != nil {
s.logger.Error("error scanning rows from SQLite schema_migrations table definition", mlog.Err(err))
return false, err
}
data = append(data, row)
}
nameColumnFound := false
for _, row := range data {
if len(row) >= 2 && *row[1] == "dirty" {
nameColumnFound = true
break
}
}
return nameColumnFound, nil
}
func (s *SQLStore) getLegacySchemaVersion() (uint32, error) {
query := s.getQueryBuilder(s.db).
Select("version").
From(s.tablePrefix + "schema_migrations")
row := query.QueryRow()
var version uint32
if err := row.Scan(&version); err != nil {
s.logger.Error("error fetching legacy schema version", mlog.Err(err))
return version, err
}
return version, nil
}
func (s *SQLStore) createTempSchemaTable() error {
// squirrel doesn't support DDL query in query builder
// so, we need to use a plain old string
query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (Version bigint NOT NULL, Name varchar(64) NOT NULL, PRIMARY KEY (Version))", s.tablePrefix+tempSchemaMigrationTableName)
if _, err := s.db.Exec(query); err != nil {
s.logger.Error("failed to create temporary schema migration table", mlog.Err(err))
s.logger.Error("createTempSchemaTable error " + err.Error())
return err
}
return nil
}
func (s *SQLStore) populateTempSchemaTable(migrations []*models.Migration) error {
query := s.getQueryBuilder(s.db).
Insert(s.tablePrefix+tempSchemaMigrationTableName).
Columns("Version", "Name")
for _, migration := range migrations {
s.logger.Info("-- Registering migration", mlog.Uint32("version", migration.Version), mlog.String("name", migration.Name))
query = query.Values(migration.Version, migration.Name)
}
if _, err := query.Exec(); err != nil {
s.logger.Error("failed to insert migration records into temporary schema table", mlog.Err(err))
return err
}
return nil
}
func (s *SQLStore) useNewSchemaTable() error {
// first delete the old table, then
// rename the new table to old table's name
// renaming old schema migration table. Will delete later once the migration is
// complete, just in case.
var query string
if s.dbType == model.MysqlDBType {
query = fmt.Sprintf("RENAME TABLE `%sschema_migrations` TO `%sschema_migrations_old_temp`", s.tablePrefix, s.tablePrefix)
} else {
query = fmt.Sprintf("ALTER TABLE %sschema_migrations RENAME TO %sschema_migrations_old_temp", s.tablePrefix, s.tablePrefix)
}
if _, err := s.db.Exec(query); err != nil {
s.logger.Error("failed to rename old schema migration table", mlog.Err(err))
return err
}
// renaming new temp table to old table's name
if s.dbType == model.MysqlDBType {
query = fmt.Sprintf("RENAME TABLE `%s%s` TO `%sschema_migrations`", s.tablePrefix, tempSchemaMigrationTableName, s.tablePrefix)
} else {
query = fmt.Sprintf("ALTER TABLE %s%s RENAME TO %sschema_migrations", s.tablePrefix, tempSchemaMigrationTableName, s.tablePrefix)
}
if _, err := s.db.Exec(query); err != nil {
s.logger.Error("failed to rename temp schema table", mlog.Err(err))
return err
}
return nil
}
func (s *SQLStore) deleteOldSchemaMigrationTable() error {
query := "DROP TABLE IF EXISTS " + s.tablePrefix + "schema_migrations_old_temp"
if _, err := s.db.Exec(query); err != nil {
s.logger.Error("failed to delete old temp schema migrations table", mlog.Err(err))
return err
}
return nil
}

View File

@ -0,0 +1,100 @@
package sqlstore
import (
"testing"
"github.com/mattermost/morph/models"
"github.com/stretchr/testify/require"
)
func TestGetEmbeddedMigrations(t *testing.T) {
t.Run("should find migrations on the embedded assets", func(t *testing.T) {
migrations, err := getEmbeddedMigrations()
require.NoError(t, err)
require.NotEmpty(t, migrations)
})
}
func TestFilterMigrations(t *testing.T) {
migrations := []*models.Migration{
{Direction: models.Up, Version: 1},
{Direction: models.Down, Version: 1},
{Direction: models.Up, Version: 2},
{Direction: models.Down, Version: 2},
{Direction: models.Up, Version: 3},
{Direction: models.Down, Version: 3},
{Direction: models.Up, Version: 4},
{Direction: models.Down, Version: 4},
}
t.Run("only up migrations should be included", func(t *testing.T) {
filteredMigrations := filterMigrations(migrations, 4)
require.Len(t, filteredMigrations, 4)
for _, migration := range filteredMigrations {
require.Equal(t, models.Up, migration.Direction)
}
})
t.Run("only migrations below or equal to the legacy schema version should be included", func(t *testing.T) {
testCases := []struct {
Name string
LegacyVersion uint32
ExpectedVersions []uint32
}{
{"All should be included", 4, []uint32{1, 2, 3, 4}},
{"Only half should be included", 2, []uint32{1, 2}},
{"Three including the third should be included", 3, []uint32{1, 2, 3}},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
filteredMigrations := filterMigrations(migrations, tc.LegacyVersion)
require.Len(t, filteredMigrations, int(tc.LegacyVersion))
versions := make([]uint32, len(filteredMigrations))
for i, migration := range filteredMigrations {
versions[i] = migration.Version
}
require.ElementsMatch(t, versions, tc.ExpectedVersions)
})
}
})
t.Run("migrations should be included even if they're not sorted", func(t *testing.T) {
unsortedMigrations := []*models.Migration{
{Direction: models.Up, Version: 4},
{Direction: models.Down, Version: 4},
{Direction: models.Up, Version: 1},
{Direction: models.Down, Version: 2},
{Direction: models.Down, Version: 1},
{Direction: models.Up, Version: 3},
{Direction: models.Down, Version: 3},
{Direction: models.Up, Version: 2},
}
testCases := []struct {
Name string
LegacyVersion uint32
ExpectedVersions []uint32
}{
{"All should be included", 4, []uint32{1, 2, 3, 4}},
{"Only half should be included", 2, []uint32{1, 2}},
{"Three including the third should be included", 3, []uint32{1, 2, 3}},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
filteredMigrations := filterMigrations(unsortedMigrations, tc.LegacyVersion)
require.Len(t, filteredMigrations, int(tc.LegacyVersion))
versions := make([]uint32, len(filteredMigrations))
for i, migration := range filteredMigrations {
versions[i] = migration.Version
}
require.ElementsMatch(t, versions, tc.ExpectedVersions)
})
}
})
}

View File

@ -1 +1 @@
5.4.0
6.0.1

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -183,4 +183,42 @@ describe('Create and delete board / card', () => {
cy.get('.Kanban').invoke('scrollLeft').should('equal', 0)
})
it('GH-2520 make cut/undo/redo work in comments', () => {
const isMAC = navigator.userAgent.indexOf("Mac") !== -1
const ctrlKey = isMAC ? 'meta' : 'ctrl'
// Visit a page and create new empty board
cy.visit('/')
cy.uiCreateEmptyBoard()
// Create card
cy.log('**Create card**')
cy.get('.ViewHeader').contains('New').click()
cy.get('.CardDetail').should('exist')
cy.wait(1000)
cy.log('**Add comment**')
cy.get('.CommentsList').
findAllByTestId('preview-element').
click().
get('.CommentsList .MarkdownEditorInput').
type('Test Text')
cy.log('**Cut comment**')
cy.get('.CommentsList .MarkdownEditorInput').
type('{selectAll}').
trigger('cut').
should('have.text', '')
cy.log('**Undo comment**')
cy.get('.CommentsList .MarkdownEditorInput').
type(`{${ctrlKey}+z}`).
should('have.text', 'Test Text')
cy.log('**Redo comment**')
cy.get('.CommentsList .MarkdownEditorInput').
type(`{shift+${ctrlKey}+z}`).
should('have.text', '')
})
})

View File

@ -1,4 +1,5 @@
{
"AppBar.Tooltip": "Kapcsolt táblák kapcsolása",
"BoardComponent.add-a-group": "+ Csoport hozzáadása",
"BoardComponent.delete": "Törlés",
"BoardComponent.hidden-columns": "Rejtett oszlopok",
@ -19,10 +20,10 @@
"BoardTemplateSelector.add-template": "Új sablon",
"BoardTemplateSelector.create-empty-board": "Üres tábla készítése",
"BoardTemplateSelector.delete-template": "Törlés",
"BoardTemplateSelector.description": "Válasszon egy sablont, amely segít a kezdésben. Könnyedén testre szabhatja a sablont, hogy megfeleljen az Ön igényeinek, vagy létrehozhat egy üres táblát, hogy a nulláról kezdhesse.",
"BoardTemplateSelector.description": "Adjon hozzá egy táblát az oldalsávhoz az alább meghatározott sablonok bármelyikével, vagy kezdje elölről.",
"BoardTemplateSelector.edit-template": "Szerkesztés",
"BoardTemplateSelector.plugin.no-content-description": "Adjon hozzá egy táblát az oldalsávhoz az alább megadott sablonok bármelyikével, vagy kezdje elölről.{lineBreak} A \"{teamName}\" tagjai hozzáférhetnek az itt létrehozott táblákhoz.",
"BoardTemplateSelector.plugin.no-content-title": "Tábla létrehozása a {teamName} csapathoz",
"BoardTemplateSelector.plugin.no-content-description": "Adjon hozzá egy táblát az oldalsávhoz az alább megadott sablonok bármelyikével, vagy kezdje elölről.",
"BoardTemplateSelector.plugin.no-content-title": "Tábla létrehozása",
"BoardTemplateSelector.title": "Tábla létrehozása",
"BoardTemplateSelector.use-this-template": "Használja ezt a sablont",
"BoardsSwitcher.Title": "Táblák keresése",
@ -136,10 +137,20 @@
"EditableDayPicker.today": "Ma",
"Error.mobileweb": "Mobil web támogatás jelenleg előzetes béta állapotban van. Nem minden funkcionalitás érhető el.",
"Error.websocket-closed": "Websocket kapcsolat bezárult, kapcsolat megszakadt, Ha ez továbbra is fennáll, akkor ellenőrizze le a kiszolgáló vagy web proxy beállítását.",
"Filter.contains": "tartalmazza",
"Filter.ends-with": "végződik",
"Filter.includes": "tartalmazza",
"Filter.is": "egy",
"Filter.is-empty": "üres",
"Filter.is-not-empty": "nem üres",
"Filter.is-not-set": "nincs megadva",
"Filter.is-set": "meg van adva",
"Filter.not-contains": "nem tartalmazza",
"Filter.not-ends-with": "nem végződik",
"Filter.not-includes": "nem tartalmazza",
"Filter.not-starts-with": "nem kezdődik",
"Filter.starts-with": "kezdődik",
"FilterByText.placeholder": "szöveg szűrése",
"FilterComponent.add-filter": "+ Szűrő hozzáadása",
"FilterComponent.delete": "Törlés",
"FindBoardsDialog.IntroText": "Táblák keresése",
@ -150,6 +161,7 @@
"GroupBy.hideEmptyGroups": "{count} üres csoport elrejtése",
"GroupBy.showHiddenGroups": "{count} rejtett csoport megjelenítése",
"GroupBy.ungroup": "Csoportosítás megszüntetése",
"HideBoard.MenuOption": "Tábla elrejtése",
"KanbanCard.untitled": "Névtelen",
"Mutator.new-board-from-template": "új tábla sablon alapján",
"Mutator.new-card-from-template": "új kártya sablonból",
@ -183,8 +195,10 @@
"PropertyType.Phone": "Telefon",
"PropertyType.Select": "Kiválasztás",
"PropertyType.Text": "Szöveg",
"PropertyType.Unknown": "Ismeretlen",
"PropertyType.UpdatedBy": "Utoljára frissítette",
"PropertyType.UpdatedTime": "Utolsó frissítés ideje",
"PropertyType.Url": "URL",
"PropertyValueElement.empty": "Üres",
"RegistrationLink.confirmRegenerateToken": "Ez érvényteleníteni fogja a korábban megosztott linkeket. Folytassuk?",
"RegistrationLink.copiedLink": "Másolt!",
@ -201,7 +215,7 @@
"ShareBoard.copiedLink": "Másolt!",
"ShareBoard.copyLink": "Link másolása",
"ShareBoard.regenerate": "Token újragenerálása",
"ShareBoard.searchPlaceholder": "Személyek keresése",
"ShareBoard.searchPlaceholder": "Személyek és csatornák keresése",
"ShareBoard.teamPermissionsText": "Mindenki a {teamName} Csapatban",
"ShareBoard.tokenRegenrated": "Token újragenerálva",
"ShareBoard.userPermissionsRemoveMemberText": "Tag eltávolítása",
@ -227,11 +241,19 @@
"Sidebar.untitled-board": "(Névtelen tábla)",
"Sidebar.untitled-view": "(Névtelen nézet)",
"SidebarCategories.BlocksMenu.Move": "Áthelyezés...",
"SidebarCategories.CategoryMenu.CreateBoard": "Új tábla létrehozása",
"SidebarCategories.CategoryMenu.CreateNew": "Új kategória létrehozása",
"SidebarCategories.CategoryMenu.Delete": "Kategória törlése",
"SidebarCategories.CategoryMenu.DeleteModal.Body": "A <b>{categoryName}</b> kategóriában lévő táblák visszakerülnek a Táblák kategóriákba. Ön egyik táblából sem lesz eltávolítva.",
"SidebarCategories.CategoryMenu.DeleteModal.Title": "Törli ezt a kategóriát?",
"SidebarCategories.CategoryMenu.Update": "Kategória átnevezése",
"SidebarTour.ManageCategories.Body": "Egyéni kategóriák létrehozása és kezelése. A kategóriák felhasználó-specifikusak, így egy tábla áthelyezése a kategóriájába nem befolyásolja az ugyanazt a táblát használó többi tagot.",
"SidebarTour.ManageCategories.Title": "Kategóriák kezelése",
"SidebarTour.SearchForBoards.Body": "A táblaváltó megnyitásával (Cmd/Ctrl + K) gyorsan kereshet és adhat hozzá táblákat az oldalsávjához.",
"SidebarTour.SearchForBoards.Title": "Tábla keresése",
"SidebarTour.SidebarCategories.Body": "Az összes tábláját mostantól az új oldalsávja alá rendezi. Nincs többé váltás a munkaterületek között. A v7.2 frissítés részeként automatikusan létrejöhettek az Ön számára a korábbi munkaterületek alapján létrehozott egyszeri egyéni kategóriák. Ezeket eltávolíthatja vagy szerkesztheti tetszése szerint.",
"SidebarTour.SidebarCategories.Link": "Tudjon meg többet",
"SidebarTour.SidebarCategories.Title": "Oldalsáv kategóriák",
"TableComponent.add-icon": "Ikon hozzáadása",
"TableComponent.name": "Név",
"TableComponent.plus-new": "+ Új",
@ -248,9 +270,16 @@
"URLProperty.copiedLink": "Másolva!",
"URLProperty.copy": "Másolás",
"URLProperty.edit": "Szerkesztés",
"UndoRedoHotKeys.canRedo": "Újracsinálás",
"UndoRedoHotKeys.canRedo-with-description": "Újracsinálás {description}",
"UndoRedoHotKeys.canUndo": "Visszavonás",
"UndoRedoHotKeys.canUndo-with-description": "Visszavonás {description}",
"UndoRedoHotKeys.cannotRedo": "Nincs mit újracsinálni",
"UndoRedoHotKeys.cannotUndo": "Nincs mit visszavonni",
"ValueSelector.noOptions": "Nincsenek lehetőségek. Kezdjen el gépelni, hogy hozzáadja az elsőt!",
"ValueSelector.valueSelector": "Érték kiválasztó",
"ValueSelectorLabel.openMenu": "Menü megnyitása",
"VersionMessage.help": "Tekintse meg ezen verzió újdonságait.",
"View.AddView": "Nézet hozzáadása",
"View.Board": "Tábla",
"View.DeleteView": "Nézet törlése",
@ -260,7 +289,7 @@
"View.NewCalendarTitle": "Naptár nézet",
"View.NewGalleryTitle": "Galéria nézet",
"View.NewTableTitle": "Táblázat nézet",
"View.NewTemplateTitle": "Névtelen sablon",
"View.NewTemplateTitle": "Névtelen",
"View.Table": "Táblázat",
"ViewHeader.add-template": "Új sablon",
"ViewHeader.delete-template": "Törlés",
@ -304,7 +333,8 @@
"Workspace.editing-board-template": "Ön egy sablon táblát szerkeszt.",
"boardSelector.confirm-link-board": "Kösse össze a táblát csatornával",
"boardSelector.confirm-link-board-button": "Igen, kösse össze a táblát",
"boardSelector.confirm-link-board-subtext": "A \"{boardName}\" tábla összekapcsolása ezzel a csatornával a csatorna minden tagjának \"Szerkesztő\" hozzáférést biztosít a táblához. Biztos benne, hogy szeretné összekapcsolni?",
"boardSelector.confirm-link-board-subtext": "Ha a \"{boardName}\" táblát összekapcsolja a csatornával, a csatorna minden tagja (meglévő és új) képes lesz szerkeszteni azt. A táblát bármikor leválaszthatja a csatornáról.",
"boardSelector.confirm-link-board-subtext-with-other-channel": "Amikor a \"{boardName}\" táblát összekapcsolja a csatornával, a csatorna minden tagja (meglévő és új) képes lesz szerkeszteni azt.{lineBreak} A tábla jelenleg egy másik csatornához van kapcsolva. Le lesz választva, amennyiben úgy dönt, hogy ide kapcsolja.",
"boardSelector.create-a-board": "Tábla létrehozása",
"boardSelector.link": "Összekapcsolás",
"boardSelector.search-for-boards": "Táblák keresése",
@ -356,6 +386,14 @@
"share-board.publish": "Közzététel",
"share-board.share": "Megosztás",
"shareBoard.channels-select-group": "Csatornák",
"shareBoard.confirm-link-channel": "Tábla összekapcsolása csatornával",
"shareBoard.confirm-link-channel-button": "Csatorna összekapcsolása",
"shareBoard.confirm-link-channel-button-with-other-channel": "Leválasztás és ide kapcsolás",
"shareBoard.confirm-link-channel-subtext": "Ha egy csatornát összekapcsol egy táblával, a csatorna minden tagja (meglévő és új) képes lesz szerkeszteni azt.",
"shareBoard.confirm-link-channel-subtext-with-other-channel": "Ha egy csatornát összekapcsol egy táblával, a csatorna minden tagja (meglévő és új) képes lesz szerkeszteni azt.{lineBreak}A tábla jelenleg egy másik csatornához van kapcsolva. Le lesz választva, amennyiben úgy dönt, hogy ide kapcsolja.",
"shareBoard.confirm-unlink.body": "Ha egy csatornát leválaszt egy tábláról, a csatorna minden tagja (meglévő és új) elveszíti a hozzáférést, kivéve, ha külön engedélyt kapott rá.",
"shareBoard.confirm-unlink.confirmBtnText": "Csatorna leválasztása",
"shareBoard.confirm-unlink.title": "Csatorna leválasztása a tábláról",
"shareBoard.lastAdmin": "A tábláknak legalább egy Adminisztárorral kell rendelkezniük",
"shareBoard.members-select-group": "Tagok",
"tutorial_tip.finish_tour": "Kész",

View File

@ -1,4 +1,5 @@
{
"AppBar.Tooltip": "링크된 보드로 이동",
"BoardComponent.add-a-group": "+ 그룹 추가하기",
"BoardComponent.delete": "삭제하기",
"BoardComponent.hidden-columns": "숨겨진 열",
@ -8,31 +9,39 @@
"BoardComponent.no-property-title": "속성 {property}이(가) 빈 항목은 여기로 이동됩니다. 이 열은 제거할 수 없습니다.",
"BoardComponent.show": "보이기",
"BoardMember.schemeAdmin": "관리자",
"BoardMember.schemeCommenter": "댓글 작성자",
"BoardMember.schemeEditor": "편집자",
"BoardMember.schemeNone": "없음",
"BoardMember.schemeViewer": "뷰어",
"BoardMember.schemeViwer": "뷰어",
"BoardMember.unlinkChannel": "링크 해제",
"BoardPage.newVersion": "새 버전의 보드가 존재합니다, 여기를 눌러 다시 불러오세요.",
"BoardPage.syncFailed": "보드가 삭제되었거나 권한이 거부되었습니다.",
"BoardTemplateSelector.add-template": "새 템플릿",
"BoardTemplateSelector.create-empty-board": "빈 보드 만들기",
"BoardTemplateSelector.delete-template": "템플릿 삭제",
"BoardTemplateSelector.description": "시작하는데 도움이 되는 템플릿을 선택하세요. 필요에 맞게 템플릿을 직접 정의하거나 빈 보드를 만들어 처음부터 시작할 수 있습니다.",
"BoardTemplateSelector.description": "아래에 정의된 템플릿을 사용하여 사이드바에 보드를 추가하거나 처음부터 시작하십시오.",
"BoardTemplateSelector.edit-template": "편집",
"BoardTemplateSelector.plugin.no-content-description": "아래의 템플릿들을 사용하거나 처음부터 새로운 보드를 만들어 사이드바에 추가합니다.{lineBreak} \"{teamName}\"의 구성원들이 여기에서 생성된 보드에 접근할 수 있습니다.",
"BoardTemplateSelector.plugin.no-content-title": "{teamName}에 보드 만들기",
"BoardTemplateSelector.plugin.no-content-description": "아래에 정의된 템플릿을 사용하여 사이드바에 보드를 추가하거나 처음부터 시작하십시오.",
"BoardTemplateSelector.plugin.no-content-title": "보드 만들기",
"BoardTemplateSelector.title": "보드 만들기",
"BoardTemplateSelector.use-this-template": "이 템플릿 사용하기",
"BoardsUnfurl.Remainder": "+{remainder} 개",
"BoardsSwitcher.Title": "보드 찾기",
"BoardsUnfurl.Limited": "보관 중인 카드로 인해 추가 세부정보가 숨겨져 있습니다",
"BoardsUnfurl.Remainder": "+{remainder} 추가",
"BoardsUnfurl.Updated": "{time}에 수정됨",
"Calculations.Options.average.displayName": "평균",
"Calculations.Options.average.label": "평균",
"Calculations.Options.count.displayName": "개수",
"Calculations.Options.count.label": "개수",
"Calculations.Options.countChecked.displayName": "확인됨",
"Calculations.Options.countChecked.label": "개수 확인됨",
"Calculations.Options.countChecked.label": "확인된 수",
"Calculations.Options.countUnchecked.displayName": "확인되지 않음",
"Calculations.Options.countUnchecked.label": "개수가 확인되지 않음",
"Calculations.Options.countUnchecked.label": "확인되지 않은 개수",
"Calculations.Options.countUniqueValue.displayName": "고윳값",
"Calculations.Options.countUniqueValue.label": "고윳값 개수",
"Calculations.Options.countUniqueValue.label": "고유 값 계산",
"Calculations.Options.countValue.displayName": "값",
"Calculations.Options.countValue.label": "값 개수",
"Calculations.Options.countValue.label": "계산 값",
"Calculations.Options.dateRange.displayName": "범위",
"Calculations.Options.dateRange.label": "범위",
"Calculations.Options.earliest.displayName": "이른 순으로",
@ -48,13 +57,18 @@
"Calculations.Options.none.displayName": "계산하기",
"Calculations.Options.none.label": "없음",
"Calculations.Options.percentChecked.displayName": "확인됨",
"Calculations.Options.percentChecked.label": "퍼센트 확인됨",
"Calculations.Options.percentChecked.label": "선택된 비율",
"Calculations.Options.percentUnchecked.displayName": "확인되지 않음",
"Calculations.Options.percentUnchecked.label": "퍼센트 확인되지 않음",
"Calculations.Options.percentUnchecked.label": "선택되지 않은 비율",
"Calculations.Options.range.displayName": "범위",
"Calculations.Options.range.label": "범위",
"Calculations.Options.sum.displayName": "더하기",
"Calculations.Options.sum.label": "더하기",
"CalendarCard.untitled": "제목 없음",
"CardActionsMenu.copiedLink": "복사!",
"CardActionsMenu.copyLink": "링크 복사하기",
"CardActionsMenu.delete": "삭제",
"CardActionsMenu.duplicate": "복제하기",
"CardBadges.title-checkboxes": "체크박스",
"CardBadges.title-comments": "댓글",
"CardBadges.title-description": "이 카드에는 설명이 있습니다",
@ -64,23 +78,32 @@
"CardDetail.add-icon": "아이콘 추가하기",
"CardDetail.add-property": "+ 속성 추가하기",
"CardDetail.addCardText": "카드 텍스트 추가하기",
"CardDetail.limited-body": "Professional 또는 Enterprise 플랜으로 업그레이드하여 보관된 카드를 보거나 보드당 무제한 보기, 카드 무제한 보기 등을 할 수 있습니다.",
"CardDetail.limited-button": "업그레이드",
"CardDetail.limited-title": "숨겨진 카드가 있습니다",
"CardDetail.moveContent": "카드 내용 이동하기",
"CardDetail.new-comment-placeholder": "댓글 추가하기...",
"CardDetailProperty.confirm-delete-heading": "속성 삭제 확인",
"CardDetailProperty.confirm-delete-subtext": "정말로 \"{propertyName}\"속성을 삭제할까요? 보드의 모든 카드에서 이 속성이 삭제됩니다.",
"CardDetailProperty.confirm-property-name-change-subtext": "정말로 \"{propertyName}\"속성을 {customText}로 바꾸시겠습니까? 이 보드에 있는 {numOfCards}개의 카드가 수정되며, 데이터가 손실될 수 있습니다.",
"CardDetailProperty.confirm-property-type-change": "속성의 유형을 변경합니다!",
"CardDetailProperty.confirm-property-type-change": "속성 유형 변경 확인하기",
"CardDetailProperty.delete-action-button": "삭제하기",
"CardDetailProperty.property-change-action-button": "속성 변경하기",
"CardDetailProperty.property-changed": "성공적으로 속성이 변경되었습니다!",
"CardDetailProperty.property-deleted": "성공적으로 {propertyName}이(가) 삭제되었습니다!",
"CardDetailProperty.property-deleted": "{propertyName}을(를) 성공적으로 삭제했습니다!",
"CardDetailProperty.property-name-change-subtext": "유형을 \"{oldPropType}\"에서 \"{newPropType}\"로",
"CardDetial.limited-link": "우리 계획에 대해 더 알아보기.",
"CardDialog.delete-confirmation-dialog-button-text": "삭제",
"CardDialog.delete-confirmation-dialog-heading": "카드 삭제 확인!",
"CardDialog.editing-template": "템플릿을 수정하는 중입니다.",
"CardDialog.nocard": "이 카드는 존재하지 않거나 사용할 수 없습니다.",
"Categories.CreateCategoryDialog.CancelText": "취소",
"Categories.CreateCategoryDialog.CreateText": "생성",
"Categories.CreateCategoryDialog.Placeholder": "카테고리 이름 지정",
"Categories.CreateCategoryDialog.UpdateText": "업데이트",
"CenterPanel.Login": "로그인",
"CenterPanel.Share": "공유",
"CloudMessage.cloud-server": "무료 클라우드 서버를 구입하십시오.",
"ColorOption.selectColor": "{color} 색 선택하기",
"Comment.delete": "삭제하기",
"CommentsList.send": "보내기",
@ -100,30 +123,62 @@
"ContentBlock.moveDown": "아래로 이동하기",
"ContentBlock.moveUp": "위로 이동하기",
"ContentBlock.text": "텍스트",
"DateRange.clear": "지우기",
"DateRange.empty": "비어 있음",
"DateRange.endDate": "종료일자",
"DateRange.today": "오늘",
"DeleteBoardDialog.confirm-cancel": "취소",
"DeleteBoardDialog.confirm-delete": "삭제",
"DeleteBoardDialog.confirm-info": "“{boardTitle}” 보드를 삭제하시겠습니까? 이 보드에 있는 모든 카드들이 삭제됩니다.",
"DeleteBoardDialog.confirm-tite": "보드 삭제 확인",
"DeleteBoardDialog.confirm-tite-template": "보드 템플릿 삭제 확인",
"DeleteBoardDialog.confirm-info-template": "{boardTitle} 보드 템플릿을 삭제 하시겠습니까?",
"DeleteBoardDialog.confirm-tite": "보드 삭제 확인하기",
"DeleteBoardDialog.confirm-tite-template": "보드 템플릿 삭제 확인하기",
"Dialog.closeDialog": "대화창 닫기",
"EditableDayPicker.today": "오늘",
"Error.mobileweb": "모바일 웹 지원은 현재 초기 베타 버전입니다. 모든 기능이 있는 것은 아닙니다.",
"Error.websocket-closed": "웹소켓 연결이 닫혀서 연결이 중단되었습니다. 이 문제가 지속되면, 서버 또는 웹 프록시 구성을 확인하세요.",
"Filter.includes": "포함",
"Filter.contains": "필터를 포함하다",
"Filter.ends-with": "~로 끝나다",
"Filter.includes": "~를 포함한다",
"Filter.is": "~이다",
"Filter.is-empty": "비어있음",
"Filter.is-not-empty": "비어있음",
"Filter.not-includes": "포함하지 않음",
"Filter.is-not-empty": "비어 있지 않음",
"Filter.is-not-set": "미설정",
"Filter.is-set": "설정",
"Filter.not-contains": "~를 포함하지 않음",
"Filter.not-ends-with": "~로 끝나지 않음",
"Filter.not-includes": "~를 포함하지 않음",
"Filter.not-starts-with": "~로 시작하지 않음",
"Filter.starts-with": "~로 시작함",
"FilterByText.placeholder": "필터값",
"FilterComponent.add-filter": "+ 필터 추가",
"FilterComponent.delete": "삭제",
"FindBoardsDialog.IntroText": "보드에서 검색",
"FindBoardsDialog.NoResultsFor": "\"{searchQuery}\" 에 대한 검색 결과가 없습니다",
"FindBoardsDialog.NoResultsSubtext": "글자를 확인 하시거나 다른 단어로 검색해 주세요.",
"FindBoardsDialog.SubTitle": "보드를 찾으려면 입력하십시오. <b>UP/DOWN</b> 버튼을 이용해서 보드를 찾아주세요. 선택은 <b>ENTER</b> , 해제는 <b>ESC</b>",
"FindBoardsDialog.Title": "보드 찾기",
"GroupBy.hideEmptyGroups": "{count} 그룹 숨기기",
"GroupBy.showHiddenGroups": "{count} 숨김 그룹 보기",
"GroupBy.ungroup": "그룹 해제",
"HideBoard.MenuOption": "보드 숨기기",
"KanbanCard.untitled": "제목 없음",
"Mutator.new-card-from-template": "템플릿에서 새 카드 만들기",
"Mutator.new-template-from-card": "카드에서 새 템플릿 만들기",
"Mutator.new-board-from-template": "템플릿에서 신규 보드 만들기",
"Mutator.new-card-from-template": "템플릿에서 신규 카드 만들기",
"Mutator.new-template-from-card": "카드에서 신규 템플릿 만들기",
"OnboardingTour.AddComments.Body": "@mention 을 이용하여 이슈에 대해 메터머스트 사용자에게 알릴 수 있습니다.",
"OnboardingTour.AddComments.Title": "댓글 작성",
"OnboardingTour.AddDescription.Body": "팀원들이 카드의 내용을 알 수 있도록 카드에 설명을 추가합니다.",
"OnboardingTour.AddDescription.Title": "설명 추가",
"OnboardingTour.AddProperties.Body": "카드에 다양한 속성을 추가하여 더욱 강력해 질 수 있습니다!",
"OnboardingTour.AddProperties.Title": "속성 추가",
"OnboardingTour.AddView.Body": "다른 레이아웃을 사용하여 보드를 구성할 새 보기를 작성하려면 여기로 이동하십시오.",
"OnboardingTour.AddView.Title": "뷰 추가",
"OnboardingTour.CopyLink.Body": "링크를 복사하여 채널, 다이렉트 메시지 또는 그룹 메시지에 붙여넣어 팀원들과 카드를 공유할 수 있습니다.",
"OnboardingTour.CopyLink.Title": "링크 복사",
"OnboardingTour.OpenACard.Body": "카드를 열어 보드가 작업을 구성하는 데 도움이 될 수 있는 강력한 방법을 알아보십시오.",
"OnboardingTour.OpenACard.Title": "카드 열기",
"OnboardingTour.ShareBoard.Body": "보드를 팀 내에서 내부적으로 공유하거나 조직 외부에서 볼 수 있도록 공개적으로 게시할 수 있습니다.",
"OnboardingTour.ShareBoard.Title": "보드 공유",
"PropertyMenu.Delete": "삭제",
"PropertyMenu.changeType": "속성 유형 변경",
@ -134,14 +189,16 @@
"PropertyType.CreatedTime": "생성 시간",
"PropertyType.Date": "날짜",
"PropertyType.Email": "전자우편",
"PropertyType.MultiSelect": "다중 선택",
"PropertyType.MultiSelect": "다중 선택하기",
"PropertyType.Number": "숫자",
"PropertyType.Person": "사람",
"PropertyType.Phone": "전화번호",
"PropertyType.Select": "선택",
"PropertyType.Text": "텍스트",
"PropertyType.Unknown": "알 수 없는 유형",
"PropertyType.UpdatedBy": "최근 수정한 사람",
"PropertyType.UpdatedTime": "최근 수정 시간",
"PropertyType.Url": "URL 주소",
"PropertyValueElement.empty": "비어있음",
"RegistrationLink.confirmRegenerateToken": "이전에 공유된 링크가 무효화됩니다. 계속하시겠습니까?",
"RegistrationLink.copiedLink": "복사되었습니다!",
@ -149,11 +206,20 @@
"RegistrationLink.description": "다른 구성원이 계정을 만들 수 있도록 이 링크를 공유하세요:",
"RegistrationLink.regenerateToken": "토큰 재성성",
"RegistrationLink.tokenRegenerated": "등록 링크가 재생성되었음",
"ShareBoard.PublishDescription": "웹 상의 모든 사용자와 읽기 전용 링크를 게시하고 공유합니다.",
"ShareBoard.PublishTitle": "웹에 게시하다",
"ShareBoard.ShareInternal": "내부공유하기",
"ShareBoard.ShareInternalDescription": "권한이 있는 사용자는 이 링크를 사용할 수 있습니다.",
"ShareBoard.Title": "보드 공유",
"ShareBoard.confirmRegenerateToken": "이전에 공유된 링크가 무효화됩니다. 계속하시겠습니까?",
"ShareBoard.copiedLink": "복사되었습니다!",
"ShareBoard.copyLink": "링크 복사",
"ShareBoard.regenerate": "토큰 재생성하기",
"ShareBoard.searchPlaceholder": "사용자 및 채널 검색",
"ShareBoard.teamPermissionsText": "{teamName}팀의 모든 사용자",
"ShareBoard.tokenRegenrated": "토큰이 재성생되었음",
"ShareBoard.userPermissionsRemoveMemberText": "멤버 제외하기",
"ShareBoard.userPermissionsYouText": "당신",
"ShareTemplate.Title": "템플릿 공유",
"Sidebar.about": "Focalboard에 대하여",
"Sidebar.add-board": "+ 보드 추가",
@ -161,15 +227,33 @@
"Sidebar.delete-board": "보드 삭제",
"Sidebar.duplicate-board": "보드 복제",
"Sidebar.export-archive": "아카이브 내보내기",
"Sidebar.import": "사이드바 가져요기",
"Sidebar.import-archive": "아카이브 들여오기",
"Sidebar.invite-users": "사용자 초대",
"Sidebar.logout": "로그아웃",
"Sidebar.no-boards-in-category": "해당 카테고리에 보드가 존재하지 않음",
"Sidebar.product-tour": "상품 둘러보기",
"Sidebar.random-icons": "임의 아이콘",
"Sidebar.set-language": "언어 설정",
"Sidebar.set-theme": "테마 설정",
"Sidebar.settings": "설정",
"Sidebar.template-from-board": "보드의 새 템플릿 추가",
"Sidebar.untitled-board": "(제목 없는 보드)",
"Sidebar.untitled-view": "(제목 없는 뷰)",
"SidebarCategories.BlocksMenu.Move": "이동 ...",
"SidebarCategories.CategoryMenu.CreateBoard": "신규 보드 만들기",
"SidebarCategories.CategoryMenu.CreateNew": "새 카테고리 만들기",
"SidebarCategories.CategoryMenu.Delete": "카테고리 삭제하기",
"SidebarCategories.CategoryMenu.DeleteModal.Body": "<b>{categoryName}</b>의 다시 보드 카테고리로 이동합니다. 당신은 어떤 보드에서도 제거되지 않았습니다.",
"SidebarCategories.CategoryMenu.DeleteModal.Title": "해당 카테고리를 삭제하시겠습니까?",
"SidebarCategories.CategoryMenu.Update": "카테고리 이름 수정하기",
"SidebarTour.ManageCategories.Body": "사용자 정의 카테고리를 만들고 관리합니다. 카테고리는 사용자별로 다르므로 보드를 사용자의 카레고리로 이동해도 동일한 보드를 사용하는 다른 구성원에게는 영향을 주지 않습니다.",
"SidebarTour.ManageCategories.Title": "카테고리 관리하기",
"SidebarTour.SearchForBoards.Body": "(Cmd/Ctrl + K)를 열어 보드를 빠르게 검색하고 사이드바에 추가합니다.",
"SidebarTour.SearchForBoards.Title": "보드 검색하기",
"SidebarTour.SidebarCategories.Body": "이제 모든 보드가 새 사이드바 아래에 정리됩니다. 워크스페이스 간 전환은 더 이상 필요 없습니다. v7.2 업그레이드의 일부로 이전 작업 공간을 기반으로 한 일회성 사용자 지정 카테고리가 자동으로 생성될 수 있습니다. 해당 기능을 통해 원하는 대로 카테고리를 제거하거나 편집할 수 있습니다.",
"SidebarTour.SidebarCategories.Link": "더 배우기",
"SidebarTour.SidebarCategories.Title": "사이드바 카테고리",
"TableComponent.add-icon": "아이콘 추가",
"TableComponent.name": "이름",
"TableComponent.plus-new": "+ 생성",
@ -182,13 +266,20 @@
"TableHeaderMenu.sort-descending": "내림차순 정렬",
"TableRow.delete": "삭제",
"TableRow.open": "열기",
"TopBar.give-feedback": "피드백하기",
"TopBar.give-feedback": "피드백 하기",
"URLProperty.copiedLink": "복사되었습니다!",
"URLProperty.copy": "복사",
"URLProperty.edit": "수정",
"UndoRedoHotKeys.canRedo": "다시 실행하기",
"UndoRedoHotKeys.canRedo-with-description": "{description} 다시 실행하기",
"UndoRedoHotKeys.canUndo": "실행 취소하기",
"UndoRedoHotKeys.canUndo-with-description": "{description} 실행 취소하기",
"UndoRedoHotKeys.cannotRedo": "다시 실행하지 않기",
"UndoRedoHotKeys.cannotUndo": "실행취소 하지 않기",
"ValueSelector.noOptions": "옵션이 없습니다. 새로 추가하려면 입력을 시작하세요!",
"ValueSelector.valueSelector": "값 선택",
"ValueSelectorLabel.openMenu": "메뉴 열기",
"VersionMessage.help": "이 버전의 새로운 기능을 확인하십시오.",
"View.AddView": "뷰 추가",
"View.Board": "보드",
"View.DeleteView": "뷰 삭제",
@ -198,6 +289,7 @@
"View.NewCalendarTitle": "달력 형태로 보기",
"View.NewGalleryTitle": "갤리리 형태로 보기",
"View.NewTableTitle": "표 형태로 보기",
"View.NewTemplateTitle": "제목 없음",
"View.Table": "표",
"ViewHeader.add-template": "새 템플릿",
"ViewHeader.delete-template": "삭제",
@ -213,30 +305,100 @@
"ViewHeader.new": "생성",
"ViewHeader.properties": "속성",
"ViewHeader.properties-menu": "속성 메뉴",
"ViewHeader.search-text": "검색 문자열",
"ViewHeader.search-text": "카드 검색하기",
"ViewHeader.select-a-template": "템플릿 선택",
"ViewHeader.set-default-template": "기본으로 설정",
"ViewHeader.sort": "정렬",
"ViewHeader.untitled": "제목 없음",
"ViewHeader.view-header-menu": "머리글 메뉴 보기",
"ViewHeader.view-menu": "뷰 메뉴",
"ViewLimitDialog.Heading": "보드 당 조회 수 제한에 도달했습니다",
"ViewLimitDialog.PrimaryButton.Title.Admin": "업그레이드",
"ViewLimitDialog.PrimaryButton.Title.RegularUser": "관리자에게 알리기",
"ViewLimitDialog.Subtext.Admin": "Professional 또는 Enterprise 플랜으로 업그레이드하여 보드당 무제한 보기, 카드 무제한 등을 이용할 수 있습니다.",
"ViewLimitDialog.Subtext.Admin.PricingPageLink": "우리의 계획에 대해 더 알아보기.",
"ViewLimitDialog.Subtext.RegularUser": "관리자에게 통지하여 프로페셔널 또는 엔터프라이즈 플랜으로 업그레이드하여 보드당 무제한 보기, 카드 무제한 등을 사용할 수 있습니다.",
"ViewLimitDialog.UpgradeImg.AltText": "이미지 업그레이드",
"ViewLimitDialog.notifyAdmin.Success": "관리자에게 알림이 왔습니다",
"ViewTitle.hide-description": "설명 숨기기",
"ViewTitle.pick-icon": "아이콘 선택",
"ViewTitle.random-icon": "임의",
"ViewTitle.remove-icon": "아이콘 제거",
"ViewTitle.show-description": "설명 보기",
"ViewTitle.untitled-board": "제목 없는 보드",
"WelcomePage.Description": "보드는 친숙한 칸반 보드 형태를 사용하여 팀간의 업무를 정의, 구성 및 추적하고 관리하는 프로젝트 관리 도구입니다",
"WelcomePage.Explore.Button": "탐색",
"WelcomePage.Description": "보드는 친숙한 칸반 보드 형태를 사용하여 팀간의 업무를 정의, 구성 및 추적하고 관리하는 프로젝트 관리 도구입니다.",
"WelcomePage.Explore.Button": "탐색하기",
"WelcomePage.Heading": "보드에 오신 것을 환영합니다",
"WelcomePage.NoThanks.Text": "아뇨, 제가 알아서 해결하겠습니다",
"Workspace.editing-board-template": "보드 템플릿을 수정하는 중입니다.",
"boardSelector.confirm-link-board": "채널에 보드 연결하기",
"boardSelector.confirm-link-board-button": "예. 보드 연결하기",
"boardSelector.confirm-link-board-subtext": "{boardName}을(를) 채널에 연결하면 채널의 모든 구성원(기존 및 새)이 해당 채널을 편집할 수 있습니다. 언제든지 채널에서 보드의 연결을 해제할 수 있습니다.",
"boardSelector.confirm-link-board-subtext-with-other-channel": "{boardName}을(를) 채널에 연결하면 채널의 모든 구성원(기존 및 새)이 해당 채널을 편집할 수 있습니다.{lineBreak} 이 보드는 현재 다른 채널에 연결되어 있습니다. 여기에 연결을 선택하면 연결이 해제됩니다.",
"boardSelector.create-a-board": "보드 만들기",
"boardSelector.link": "연결하기",
"boardSelector.search-for-boards": "보드 검색하기",
"boardSelector.title": "보드 연결하기",
"boardSelector.unlink": "연결 해제하기",
"calendar.month": "월",
"calendar.today": "오늘",
"calendar.week": "주",
"default-properties.badges": "댓글과 설명",
"cloudMessage.learn-more": "더 배우기",
"createImageBlock.failed": "파일을 업로드할 수 없습니다. 파일 크기 제한에 도달했습니다.",
"default-properties.badges": "댓글 및 설명",
"default-properties.title": "제목",
"error.back-to-home": "홈으로 돌아가기",
"error.back-to-team": "팀으로 돌아가기",
"error.board-not-found": "보드를 찾을 수 없습니다.",
"error.go-login": "로그인",
"error.invalid-read-only-board": "이 보드에 액세스할 수 없습니다. 보드에 액세스하려면 로그인하십시오.",
"error.not-logged-in": "세션이 만료되었거나 로그인하지 않았을 수 있습니다. 보드에 액세스하려면 다시 로그인하십시오.",
"error.page.title": "죄송해요, 뭔가 잘못되었어요",
"error.team-undefined": "유효한 팀이 아닙니다.",
"error.unknown": "오류가 발생했습니다.",
"generic.previous": "이전",
"imagePaste.upload-failed": "일부 파일이 업로드 되지 않았습니다. 파일 크기 제한에 도달했습니다",
"limitedCard.title": "숨겨진 카드",
"login.log-in-button": "로그인",
"login.log-in-title": "로그인",
"login.register-button": "계정이 없다면 계정을 만드세요",
"notification-box-card-limit-reached.close-tooltip": "10일 동안 잠자기",
"notification-box-card-limit-reached.contact-link": "관리자에게 알리기",
"notification-box-card-limit-reached.link": "유료 요금제로 업그레이드하기",
"notification-box-card-limit-reached.title": "{cards}개의 숨겨진 카드가 보드에 있습니다",
"notification-box-cards-hidden.title": "이 작업으로 인해 다른 카드가 숨겨졌습니다",
"notification-box.card-limit-reached.not-admin.text": "아카이브된 카드에 액세스하려면 {contactLink}을(를) 사용하여 유료 요금제로 업그레이드하십시오.",
"notification-box.card-limit-reached.text": "카드 제한에 도달했습니다,이전 카드를 보려면 {link}를 눌러주세요",
"register.login-button": "이미 계정이 있다면 로그인하세요",
"register.signup-title": "계정 등록"
"register.signup-title": "계정 등록",
"rhs-boards.add": "추가하기",
"rhs-boards.dm": "다이렉트 메시지",
"rhs-boards.gm": "그룹 메시지",
"rhs-boards.header.dm": "이 개인 메시지",
"rhs-boards.header.gm": "이 그룹 메시지",
"rhs-boards.last-update-at": "마지막 업데이트 시간: {datetime}",
"rhs-boards.link-boards-to-channel": "{channelName}에 보드 연결하기",
"rhs-boards.linked-boards": "연결된 보드",
"rhs-boards.no-boards-linked-to-channel": "{channelName}에 아직 연결된 보드가 없음",
"rhs-boards.no-boards-linked-to-channel-description": "Boards는 익숙한 Kanban 보드 뷰를 사용하여 팀 전체의 작업을 정의, 구성, 추적 및 관리하는 데 도움이 되는 프로젝트 관리 도구입니다.",
"rhs-boards.unlink-board": "보드 연결해제하기",
"rhs-channel-boards-header.title": "보드",
"share-board.publish": "게재하기",
"share-board.share": "공유하기",
"shareBoard.channels-select-group": "채널",
"shareBoard.confirm-link-channel": "채널에 보드 연결하기",
"shareBoard.confirm-link-channel-button": "채널 연결하기",
"shareBoard.confirm-link-channel-button-with-other-channel": "연결 및 연결해제하기",
"shareBoard.confirm-link-channel-subtext": "채널을 보드에 연결하면 채널의 모든 구성원(기존 및 신규)이 해당 채널을 편집할 수 있습니다.",
"shareBoard.confirm-link-channel-subtext-with-other-channel": "채널을 보드에 연결하면 채널의 모든 구성원(기존 및 신규)이 해당 채널을 편집할 수 있습니다.{lineBreak}이 보드는 현재 다른 채널에 연결되어 있습니다. 여기에 연결을 선택하면 연결이 해제됩니다.",
"shareBoard.confirm-unlink.body": "보드에서 채널 연결을 해제하면 채널의 모든 구성원(기존 및 새)이 개별적으로 권한이 부여되지 않는 한 해당 채널에 대한 액세스 권한을 잃게 됩니다.",
"shareBoard.confirm-unlink.confirmBtnText": "채널 연결 해제하기",
"shareBoard.confirm-unlink.title": "보드에서 채널 연결 해제하기",
"shareBoard.lastAdmin": "보드에는 최소한 한명 이상의 관리자 있어야 합니다",
"shareBoard.members-select-group": "멤버",
"tutorial_tip.finish_tour": "완료",
"tutorial_tip.got_it": "알겠습니다",
"tutorial_tip.ok": "다음",
"tutorial_tip.out": "이 도움말을 선택 해제합니다.",
"tutorial_tip.seen": "전에 본적 있나요?"
}

View File

@ -250,6 +250,10 @@
"SidebarTour.ManageCategories.Body": "Maak en beheer aangepaste categorieën. Categorieën zijn gebruikersspecifiek, dus het verplaatsen van een bord naar jouw categorie heeft geen invloed op andere leden die hetzelfde bord gebruiken.",
"SidebarTour.ManageCategories.Title": "Categorieën beheren",
"SidebarTour.SearchForBoards.Body": "Open de bordenswitcher (Cmd/Ctrl + K) om snel borden te zoeken en toe te voegen aan je zijbalk.",
"SidebarTour.SearchForBoards.Title": "Borden zoeken",
"SidebarTour.SidebarCategories.Body": "Al je borden zijn nu georganiseerd in je nieuwe zijbalk. Niet meer schakelen tussen werkruimten. Eenmalige zelfgemaakte categorieën gebaseerd op jouw vorige workspaces kunnen automatisch gemaakt zijn voor jou als onderdeel van jouw v7.2 upgrade. Deze kunnen worden verwijderd of aangepast aan jouw voorkeur.",
"SidebarTour.SidebarCategories.Link": "Meer info",
"SidebarTour.SidebarCategories.Title": "Zijbalk categorieën",
"TableComponent.add-icon": "Pictogram toevoegen",
"TableComponent.name": "Naam",
"TableComponent.plus-new": "+ Nieuw",
@ -285,7 +289,7 @@
"View.NewCalendarTitle": "Kalenderweergave",
"View.NewGalleryTitle": "Galerie bekijken",
"View.NewTableTitle": "Tabelweergave",
"View.NewTemplateTitle": "Naamloos sjabloon",
"View.NewTemplateTitle": "Naamloos",
"View.Table": "Tabel",
"ViewHeader.add-template": "Nieuw sjabloon",
"ViewHeader.delete-template": "Verwijderen",
@ -328,8 +332,9 @@
"WelcomePage.NoThanks.Text": "Nee bedankt, ik zoek het zelf wel uit",
"Workspace.editing-board-template": "Je bent een bordsjabloon aan het bewerken.",
"boardSelector.confirm-link-board": "Koppel bord aan kanaal",
"boardSelector.confirm-link-board-button": "Bord koppelen",
"boardSelector.confirm-link-board-subtext": "Het linken van het \"{boardName}\" bord naar dit kanaal zou alle leden van dit kanaal \"Editor\"-toegang geven tot het bord. Weet je zeker dat je het wilt linken?",
"boardSelector.confirm-link-board-button": "Ja, koppel het bord",
"boardSelector.confirm-link-board-subtext": "Wanneer je \"{boardName}\" aan het kanaal koppelt, kunnen alle leden van het kanaal (bestaande en nieuwe) het bewerken. Je kan de koppeling van een bord naar een kanaal op elk moment ongedaan maken.",
"boardSelector.confirm-link-board-subtext-with-other-channel": "Wanneer je \"{boardName}\" aan het kanaal koppelt zullen alle leden van het kanaal (bestaande en nieuwe) het kunnen bewerken.{lineBreak} Dit board is momenteel gekoppeld aan een ander kanaal. Het zal worden ontkoppeld als je ervoor kiest om het hier te koppelen.",
"boardSelector.create-a-board": "Maak een bord",
"boardSelector.link": "Link",
"boardSelector.search-for-boards": "Zoeken naar borden",
@ -381,6 +386,14 @@
"share-board.publish": "Publiceren",
"share-board.share": "Delen",
"shareBoard.channels-select-group": "Kanalen",
"shareBoard.confirm-link-channel": "Bord koppelen aan kanaal",
"shareBoard.confirm-link-channel-button": "Kanaal koppelen",
"shareBoard.confirm-link-channel-button-with-other-channel": "Koppel en ontkoppel hier",
"shareBoard.confirm-link-channel-subtext": "Wanneer je het bord aan het kanaal koppelt zullen alle leden van het kanaal (bestaande en nieuwe) het kunnen bewerken.",
"shareBoard.confirm-link-channel-subtext-with-other-channel": "Wanneer je een kanaal aan een bord koppelt zullen alle leden van het kanaal (bestaande en nieuwe) het kunnen bewerken.{lineBreak} Dit board is momenteel gekoppeld aan een ander kanaal. Het zal worden ontkoppeld als je ervoor kiest om het hier te koppelen.",
"shareBoard.confirm-unlink.body": "Wanneer een kanaal afkoppelt van een bord zullen alle leden van het kanaal (bestaande en nieuwe) geen toegang meer hebben tot ze apart toegang gegeven worden.",
"shareBoard.confirm-unlink.confirmBtnText": "Kanaal ontkoppelen",
"shareBoard.confirm-unlink.title": "Kanaal loskoppelen van bord",
"shareBoard.lastAdmin": "Besturen moeten ten minste één beheerder hebben",
"shareBoard.members-select-group": "Leden",
"tutorial_tip.finish_tour": "Klaar",

View File

@ -136,10 +136,20 @@
"EditableDayPicker.today": "Idag",
"Error.mobileweb": "Webbåtkomst via mobilen är i tidig betaversion. All funktionalitet kanske inte är tillgänglig.",
"Error.websocket-closed": "Websocketanslutningen stängdes då anslutningen avbröts. Om detta fortgår, kontrollera din server eller webproxykonfigurationen.",
"Filter.contains": "innehåller",
"Filter.ends-with": "slutar med",
"Filter.includes": "inkluderar",
"Filter.is": "är",
"Filter.is-empty": "är tomt",
"Filter.is-not-empty": "är inte tomt",
"Filter.is-not-set": "är inte inställd",
"Filter.is-set": "är inställd",
"Filter.not-contains": "innehåller inte",
"Filter.not-ends-with": "slutar inte med",
"Filter.not-includes": "inkluderar inte",
"Filter.not-starts-with": "börjar inte med",
"Filter.starts-with": "börjar med",
"FilterByText.placeholder": "filtrera text",
"FilterComponent.add-filter": "+ Lägg till filter",
"FilterComponent.delete": "Radera",
"FindBoardsDialog.IntroText": "Sök efter boards",
@ -184,8 +194,10 @@
"PropertyType.Phone": "Telefon",
"PropertyType.Select": "Alternativ",
"PropertyType.Text": "Text",
"PropertyType.Unknown": "Okänd",
"PropertyType.UpdatedBy": "Senast ändrad av",
"PropertyType.UpdatedTime": "Senast uppdaterad",
"PropertyType.Url": "URL",
"PropertyValueElement.empty": "Tom",
"RegistrationLink.confirmRegenerateToken": "Det här kommer att göra tidigare delade länkar ogiltiga. Vill du fortsätta?",
"RegistrationLink.copiedLink": "Kopierad!",
@ -228,11 +240,16 @@
"Sidebar.untitled-board": "(Tavla saknar titel)",
"Sidebar.untitled-view": "(vy utan titel)",
"SidebarCategories.BlocksMenu.Move": "Flytta till...",
"SidebarCategories.CategoryMenu.CreateBoard": "Skapa nytt Board",
"SidebarCategories.CategoryMenu.CreateNew": "Skapa ny kategori",
"SidebarCategories.CategoryMenu.Delete": "Ta bort kategori",
"SidebarCategories.CategoryMenu.DeleteModal.Body": "Boards i <b>{categoryName}</b> flyttas tillbaka till kategorierna Boards. Du har inte tagits bort från några Boards.",
"SidebarCategories.CategoryMenu.DeleteModal.Title": "Radera kategorin?",
"SidebarCategories.CategoryMenu.Update": "Byt namn på kategori",
"SidebarTour.ManageCategories.Body": "Skapa och hantera egna kategorier. Kategorier är användarspecifika, så om du flyttar en Board till din kategori påverkas inte andra medlemmar som använder samma Board.",
"SidebarTour.ManageCategories.Title": "Hantera kategorier",
"SidebarTour.SearchForBoards.Body": "Öppna Board-växlaren (Cmd/Ctrl + K) för att snabbt söka och lägga till boards i sidofältet.",
"SidebarTour.SearchForBoards.Title": "Sök efter boards",
"TableComponent.add-icon": "Lägg till ikon",
"TableComponent.name": "Namn",
"TableComponent.plus-new": "+ Ny",

View File

@ -74,7 +74,7 @@ type BoardsAndBlocksPatch = {
blockPatches: BlockPatch[],
}
type PropertyTypeEnum = 'text' | 'number' | 'select' | 'multiSelect' | 'date' | 'person' | 'file' | 'checkbox' | 'url' | 'email' | 'phone' | 'createdTime' | 'createdBy' | 'updatedTime' | 'updatedBy' | 'unknown'
type PropertyTypeEnum = 'text' | 'number' | 'select' | 'multiSelect' | 'date' | 'person' | 'multiPerson' | 'file' | 'checkbox' | 'url' | 'email' | 'phone' | 'createdTime' | 'createdBy' | 'updatedTime' | 'updatedBy' | 'unknown'
interface IPropertyOption {
id: string

View File

@ -139,51 +139,6 @@ exports[`components/cardDialog already following card 1`] = `
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -211,51 +166,6 @@ exports[`components/cardDialog already following card 1`] = `
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -883,51 +793,6 @@ exports[`components/cardDialog return cardDialog menu content 1`] = `
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -955,51 +820,6 @@ exports[`components/cardDialog return cardDialog menu content 1`] = `
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -1166,51 +986,6 @@ exports[`components/cardDialog return cardDialog menu content and cancel delete
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -1238,51 +1013,6 @@ exports[`components/cardDialog return cardDialog menu content and cancel delete
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -1449,51 +1179,6 @@ exports[`components/cardDialog should match snapshot 1`] = `
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -1521,51 +1206,6 @@ exports[`components/cardDialog should match snapshot 1`] = `
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -88,53 +88,6 @@ exports[`components/centerPanel Clicking on the Hidden card count should open a
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -833,53 +786,6 @@ exports[`components/centerPanel return centerPanel and click on card to show car
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -1433,53 +1339,6 @@ exports[`components/centerPanel return centerPanel and click on new card to edit
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -2111,53 +1970,6 @@ exports[`components/centerPanel return centerPanel and press touch 1 with readon
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -2584,53 +2396,6 @@ exports[`components/centerPanel return centerPanel and press touch ctrl+d for on
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -3279,53 +3044,6 @@ exports[`components/centerPanel return centerPanel and press touch del for one c
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -3974,53 +3692,6 @@ exports[`components/centerPanel return centerPanel and press touch esc for one c
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -4669,53 +4340,6 @@ exports[`components/centerPanel return centerPanel and press touch esc for one c
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -5364,53 +4988,6 @@ exports[`components/centerPanel return centerPanel and press touch esc for two c
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -6059,53 +5636,6 @@ exports[`components/centerPanel return centerPanel and press touch esc for two c
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -6754,53 +6284,6 @@ exports[`components/centerPanel return centerPanel and press touch esc for two c
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -7449,53 +6932,6 @@ exports[`components/centerPanel return centerPanel and select one card and click
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -8144,53 +7580,6 @@ exports[`components/centerPanel return centerPanel and select one card and click
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -8839,53 +8228,6 @@ exports[`components/centerPanel should match snapshot for Gallery 1`] = `
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -9161,53 +8503,6 @@ exports[`components/centerPanel should match snapshot for Kanban 1`] = `
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -10246,53 +9541,6 @@ exports[`components/centerPanel should match snapshot for Table 1`] = `
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,164 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/components/confirmAddUserForNotifications should match snapshot 1`] = `
<div>
<div
class="Dialog dialog-back confirmation-dialog-box"
>
<div
class="backdrop"
/>
<div
class="wrapper"
>
<div
class="dialog"
role="dialog"
>
<div
class="toolbar"
>
<button
aria-label="Close dialog"
class="IconButton dialog__close size--medium"
title="Close dialog"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="toolbar--right"
/>
</div>
<div
class="box-area"
title="Confirmation Dialog Box"
>
<h3
class="text-heading5"
>
Add fake-username to board
</h3>
<div
class="sub-text"
>
<div
class="ConfirmAddUserForNotifications"
>
<p>
fake-username is not a member of the board, and will not received any notifications about it.
</p>
<p>
Do you want to add fake-username to the board?
</p>
<div
class="permissions-title"
>
<label>
Permissions
</label>
</div>
<div
class="select css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class=" css-1s2u09g-control"
>
<div
class=" css-319lph-ValueContainer"
>
<div
class=" css-qc6sy-singleValue"
>
Editor
</div>
<div
class=" css-6j8wv5-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
id="react-select-2-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
class=" css-1hb7zxy-IndicatorsContainer"
>
<span
class=" css-1okebmr-indicatorSeparator"
/>
<div
aria-hidden="true"
class=" css-tlfecz-indicatorContainer"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
>
<path
d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="action-buttons"
>
<button
class="Button emphasis--tertiary size--medium"
title="Cancel"
type="button"
>
<span>
Cancel
</span>
</button>
<button
class="Button filled size--medium"
title="Add to board"
type="submit"
>
<span>
Add to board
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -472,53 +472,6 @@ exports[`components/contentBlock should match snapshot with textBlock 1`] = `
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
title
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div

View File

@ -9,51 +9,6 @@ exports[`components/markdownEditor should match snapshot 1`] = `
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="test-id"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
@ -67,53 +22,6 @@ exports[`components/markdownEditor should match snapshot with initial text 1`] =
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="test-id"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
some initial text already set
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
@ -122,55 +30,7 @@ exports[`components/markdownEditor should match snapshot with on click on previe
<div>
<div
class="MarkdownEditor octo-editor classname-test active"
>
<div
class="MarkdownEditorInput"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="test-id"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
some initial text already set
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
/>
</div>
`;

View File

@ -152,53 +152,6 @@ exports[`components/viewTitle should match snapshot 1`] = `
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -246,53 +199,6 @@ exports[`components/viewTitle should match snapshot readonly 1`] = `
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -356,53 +262,6 @@ exports[`components/viewTitle show description 1`] = `
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -362,53 +362,6 @@ exports[`src/components/workspace return workspace and showcard 1`] = `
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -931,53 +884,6 @@ exports[`src/components/workspace return workspace readonly and showcard 1`] = `
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -1597,53 +1503,6 @@ exports[`src/components/workspace should match snapshot 1`] = `
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -2166,53 +2025,6 @@ exports[`src/components/workspace should match snapshot with readonly 1`] = `
class="octo-editor-preview"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
description
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -123,6 +123,7 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
properties: {
trackingTemplateId: 'template_id_2',
},
createdBy: 'system',
},
],
membersInBoards: {
@ -151,6 +152,7 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
properties: {
trackingTemplateId: 'template_id_global',
},
createdBy: 'system',
}],
},
}

View File

@ -79,8 +79,8 @@ describe('components/boardTemplateSelector/boardTemplateSelectorItem', () => {
id: 'global-1',
title: 'Template global',
teamId: '0',
createdBy: 'user-1',
modifiedBy: 'user-1',
createdBy: 'system',
modifiedBy: 'system',
createAt: 10,
updateAt: 20,
deleteAt: 0,

View File

@ -44,7 +44,7 @@ const BoardTemplateSelectorItem = (props: Props) => {
<span className='template-name'>{template.title || intl.formatMessage({id: 'View.NewTemplateTitle', defaultMessage: 'Untitled'})}</span>
{/* don't show template menu options for default templates */}
{template.teamId !== Constants.globalTeamId &&
{template.createdBy !== Constants.SystemUserID &&
<div className='actions'>
<BoardPermissionGate
boardId={template.id}

View File

@ -22,51 +22,6 @@ exports[`components/cardDetail/cardDetailContents should match snapshot 1`] = `
Add a description...
</p>
</div>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="test-id"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -528,6 +528,31 @@ exports[`components/cardDetail/CardDetailProperties should show property types m
class="noicon"
/>
</div>
<div
aria-label="Multi person"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Multi person
</div>
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Checkbox"
class="MenuOption TextOption menu-option"

View File

@ -10,18 +10,22 @@ import {Provider as ReduxProvider} from 'react-redux'
import {mocked} from 'jest-mock'
import mutator from '../mutator'
import {IUser} from '../user'
import {Utils} from '../utils'
import octoClient from '../octoClient'
import {TestBlockFactory} from '../test/testBlockFactory'
import {mockDOM, mockStateStore, wrapDNDIntl} from '../testUtils'
import CardDialog from './cardDialog'
jest.mock('../mutator')
jest.mock('../octoClient')
jest.mock('../utils')
jest.mock('draft-js/lib/generateRandomKey', () => () => '123')
const mockedUtils = mocked(Utils, true)
const mockedMutator = mocked(mutator, true)
const mockedOctoClient = mocked(octoClient, true)
mockedUtils.createGuid.mockReturnValue('test-id')
mockedUtils.isFocalboardPlugin.mockReturnValue(true)
@ -84,6 +88,8 @@ describe('components/cardDialog', () => {
blockSubscriptions: [],
},
}
mockedOctoClient.searchTeamUsers.mockResolvedValue(Object.values(state.users.boardUsers) as IUser[])
const store = mockStateStore([], state)
beforeEach(() => {
jest.clearAllMocks()

View File

@ -10,6 +10,8 @@ import {mockDOM, mockStateStore, wrapDNDIntl} from '../testUtils'
import {TestBlockFactory} from '../test/testBlockFactory'
import {IPropertyTemplate} from '../blocks/board'
import {Utils} from '../utils'
import {IUser} from '../user'
import octoClient from '../octoClient'
import Mutator from '../mutator'
import {Constants} from '../constants'
@ -26,11 +28,13 @@ jest.mock('react-router-dom', () => {
}
})
jest.mock('../utils')
jest.mock('../octoClient')
jest.mock('../mutator')
jest.mock('../telemetry/telemetryClient')
jest.mock('draft-js/lib/generateRandomKey', () => () => '123')
const mockedUtils = mocked(Utils, true)
const mockedMutator = mocked(Mutator, true)
const mockedOctoClient= mocked(octoClient, true)
mockedUtils.createGuid.mockReturnValue('test-id')
mockedUtils.generateClassName = jest.requireActual('../utils').Utils.generateClassName
describe('components/centerPanel', () => {
@ -139,6 +143,7 @@ describe('components/centerPanel', () => {
},
},
}
mockedOctoClient.searchTeamUsers.mockResolvedValue(Object.values(state.users.boardUsers) as IUser[])
const store = mockStateStore([], state)
beforeAll(() => {
mockDOM()

View File

@ -0,0 +1,32 @@
@import '../styles/z-index';
.ConfirmAddUserForNotifications {
display: flex;
flex-direction: column;
align-items: center;
.select {
text-align: left;
width: 250px;
margin-top: 10px;
}
.permissions-title {
width: 250px;
position: relative;
top: -3px;
left: 6px;
height: 0;
text-align: left;
font-size: 12px;
label {
@include z-index(modal-permission-label);
position: absolute;
padding: 0 5px;
margin: 0;
background-color: rgb(var(--center-channel-bg-rgb));
color: rgba(var(--center-channel-color-rgb), 0.8);
}
}
}

View File

@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import '@testing-library/jest-dom'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import {wrapDNDIntl} from '../testUtils'
import {IUser} from '../user'
import ConfirmAddUserForNotifications from './confirmAddUserForNotifications'
describe('/components/confirmAddUserForNotifications', () => {
it('should match snapshot', async () => {
const result = render(
wrapDNDIntl(
<ConfirmAddUserForNotifications
user={{id: 'fake-user-id', username: 'fake-username'} as IUser}
onConfirm={jest.fn()}
onClose={jest.fn()}
/>,
),
)
expect(result.container).toMatchSnapshot()
})
it('confirm button click, run onConfirm Function once', () => {
const onConfirm = jest.fn()
const result = render(
wrapDNDIntl(
<ConfirmAddUserForNotifications
user={{id: 'fake-user-id', username: 'fake-username'} as IUser}
onConfirm={onConfirm}
onClose={jest.fn()}
/>,
),
)
userEvent.click(result.getByTitle('Add to board'))
expect(onConfirm).toBeCalledTimes(1)
})
it('cancel button click runs onClose function', () => {
const onClose = jest.fn()
const result = render(
wrapDNDIntl(
<ConfirmAddUserForNotifications
user={{id: 'fake-user-id', username: 'fake-username'} as IUser}
onConfirm={jest.fn()}
onClose={onClose}
/>,
),
)
userEvent.click(result.getByTitle('Cancel'))
expect(onClose).toBeCalledTimes(1)
})
})

View File

@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useRef} from 'react'
import Select from 'react-select'
import {useIntl, FormattedMessage} from 'react-intl'
import {IUser} from '../user'
import ConfirmationDialog from './confirmationDialogBox'
import './confirmAddUserForNotifications.scss'
type Props = {
user: IUser,
onConfirm: (userId: string, role: string) => void
onClose: () => void
}
const ConfirmAddUserForNotifications = (props: Props): JSX.Element => {
const {user} = props
const [newUserRole, setNewUserRole] = useState('Editor')
const userRole = useRef<string>('Editor')
const intl = useIntl()
const roleOptions = [
{id: 'Admin', label: intl.formatMessage({id:'PersonProperty.add-user-admin-role', defaultMessage:'Admin'})},
{id: 'Editor', label: intl.formatMessage({id:'PersonProperty.add-user-editor-role', defaultMessage:'Editor'})},
{id: 'Commenter', label: intl.formatMessage({id:'PersonProperty.add-user-commenter-role', defaultMessage:'Commenter'})},
{id: 'Viewer', label: intl.formatMessage({id:'PersonProperty.add-user-viewer-role', defaultMessage:'Viewer'})},
]
const subText = (
<div className='ConfirmAddUserForNotifications'>
<p>
<FormattedMessage
id='person.add-user-to-board-warning'
defaultMessage='{username} is not a member of the board, and will not received any notifications about it.'
values={{username: props.user.username}}
/>
</p>
<p>
<FormattedMessage
id='person.add-user-to-board-question'
defaultMessage='Do you want to add {username} to the board?'
values={{username: props.user.username}}
/>
</p>
<div className='permissions-title'>
<label>
<FormattedMessage
id='person.add-user-to-board-permissions'
defaultMessage='Permissions'
/>
</label>
</div>
<Select
className='select'
getOptionLabel={(o: {id: string, label: string}) => o.label}
getOptionValue={(o: {id: string, label: string}) => o.id}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }) }}
menuPortalTarget={document.body}
options={roleOptions}
onChange={(option) => {
setNewUserRole(option?.id || 'Editor')
userRole.current = option?.id || 'Editor'
}}
value={roleOptions.find((o) => o.id === newUserRole)}
/>
</div>
)
return (
<ConfirmationDialog
dialogBox={{
heading: intl.formatMessage({id: 'person.add-user-to-board', defaultMessage: 'Add {username} to board'}, {username: props.user.username}),
subText,
confirmButtonText: intl.formatMessage({id: 'person.add-user-to-board-confirm-button', defaultMessage: 'Add to board'}),
onConfirm: () => props.onConfirm(user.id, userRole.current),
onClose: props.onClose,
}}
/>
)
}
export default ConfirmAddUserForNotifications

View File

@ -9,51 +9,6 @@ exports[`components/content/TextElement return a textElement 1`] = `
class="octo-editor-preview octo-placeholder"
data-testid="preview-element"
/>
<div
class="MarkdownEditorInput MarkdownEditorInput--IsNotEditing"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<br
data-text="true"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -64,8 +64,7 @@ const MarkdownEditor = (props: Props): JSX.Element => {
const element = (
<div className={`MarkdownEditor octo-editor ${props.className || ''} ${isEditing ? 'active' : ''}`}>
{!isEditing && previewElement}
{editorElement}
{isEditing ? editorElement : previewElement}
</div>
)

View File

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactElement} from 'react'
import {FormattedMessage} from 'react-intl'
import {EntryComponentProps} from '@draft-js-plugins/mention/lib/MentionSuggestions/Entry/Entry'
import GuestBadge from '../../../widgets/guestBadge'
@ -34,6 +35,13 @@ const Entry = (props: EntryComponentProps): ReactElement => {
<div className={theme?.mentionSuggestionsEntryText}>
{mention.displayName}
</div>
{!mention.isBoardMember &&
<div className={theme?.mentionSuggestionsEntryText}>
<FormattedMessage
id='MentionSuggestion.is-not-board-member'
defaultMessage='(not board member)'
/>
</div>}
</div>
</div>
)

View File

@ -4,10 +4,6 @@
align-items: center;
}
&--IsNotEditing {
display: none;
}
span[data-testid='mentionText'] {
background: rgba(var(--button-bg-rgb), 0.16);
border-radius: 4px;

View File

@ -16,8 +16,14 @@ import {debounce} from "lodash"
import {useAppSelector} from '../../store/hooks'
import {IUser} from '../../user'
import {getBoardUsersList} from '../../store/users'
import {getBoardUsersList, getMe} from '../../store/users'
import createLiveMarkdownPlugin from '../live-markdown-plugin/liveMarkdownPlugin'
import {useHasPermissions} from '../../hooks/permissions'
import {Permission} from '../../constants'
import {BoardMember} from '../../blocks/board'
import mutator from '../../mutator'
import ConfirmAddUserForNotifications from '../confirmAddUserForNotifications'
import RootPortal from '../rootPortal'
import './markdownEditorInput.scss'
@ -34,10 +40,13 @@ import Entry from './entryComponent/entryComponent'
const imageURLForUser = (window as any).Components?.imageURLForUser
type MentionUser = {
user: IUser,
name: string
avatar: string
is_bot: boolean
is_guest: boolean
displayName: string
isBoardMember: boolean
}
type Props = {
@ -50,31 +59,44 @@ type Props = {
}
const MarkdownEditorInput = (props: Props): ReactElement => {
const {onChange, onFocus, onBlur, initialText, id, isEditing} = props
const {onChange, onFocus, onBlur, initialText, id} = props
const boardUsers = useAppSelector<IUser[]>(getBoardUsersList)
const board = useAppSelector(getCurrentBoard)
const clientConfig = useAppSelector<ClientConfig>(getClientConfig)
const ref = useRef<Editor>(null)
const allowAddUsers = useHasPermissions(board.teamId, board.id, [Permission.ManageBoardRoles])
const [confirmAddUser, setConfirmAddUser] = useState<IUser|null>(null)
const me = useAppSelector<IUser|null>(getMe)
const [suggestions, setSuggestions] = useState<Array<MentionUser>>([])
const loadSuggestions = async (term: string) => {
let users: Array<IUser>
if (board && board.type === BoardTypeOpen) {
if (!me?.is_guest && (allowAddUsers || (board && board.type === BoardTypeOpen))) {
users = await octoClient.searchTeamUsers(term)
} else {
users = boardUsers
.filter(user => {
// no search term
if (!term) return true
// does the search term occur anywhere in the display name?
return Utils.getUserDisplayName(user, clientConfig.teammateNameDisplay).includes(term)
})
// first 10 results
.slice(0, 10)
}
const mentions = users.map(
(user) => ({
const mentions: Array<MentionUser> = users.map(
(user: IUser): MentionUser => ({
name: user.username,
avatar: `${imageURLForUser ? imageURLForUser(user.id) : ''}`,
is_bot: user.is_bot,
is_guest: user.is_guest,
displayName: Utils.getUserDisplayName(user, clientConfig.teammateNameDisplay)}
))
displayName: Utils.getUserDisplayName(user, clientConfig.teammateNameDisplay),
isBoardMember: Boolean(boardUsers.find((u) => u.id === user.id)),
user: user,
}))
setSuggestions(mentions)
}
@ -86,15 +108,30 @@ const MarkdownEditorInput = (props: Props): ReactElement => {
loadSuggestions('')
}, [])
const generateEditorState = (text?: string) => {
const state = EditorState.createWithContent(ContentState.createFromText(text || ''))
return EditorState.moveSelectionToEnd(state)
}
const [editorState, setEditorState] = useState(() => {
return generateEditorState(initialText)
})
const [editorState, setEditorState] = useState(() => generateEditorState(initialText))
const addUser = useCallback(async (userId: string, role: string) => {
const newMember = {
boardId: board.id,
userId: userId,
roles: role,
schemeAdmin: role === 'Admin',
schemeEditor: role === 'Admin' || role === 'Editor',
schemeCommenter: role === 'Admin' || role === 'Editor' || role === 'Commenter',
schemeViewer: role === 'Admin' || role === 'Editor' || role === 'Commenter' || role === 'Viewer',
} as BoardMember
setConfirmAddUser(null)
setEditorState(EditorState.moveSelectionToEnd(editorState))
ref.current?.focus()
await mutator.createBoardMember(board.id, newMember.userId)
mutator.updateBoardMember(newMember, {...newMember, schemeAdmin: false, schemeEditor: true, schemeCommenter: true, schemeViewer: true})
}, [board, editorState])
const [initialTextCache, setInitialTextCache] = useState<string | undefined>(initialText)
@ -132,16 +169,13 @@ const MarkdownEditorInput = (props: Props): ReactElement => {
return {plugins, MentionSuggestions, EmojiSuggestions}
}, [])
useEffect(() => {
if (isEditing) {
if (initialText === '') {
setEditorState(EditorState.createEmpty())
} else {
setEditorState(EditorState.moveSelectionToEnd(editorState))
}
setTimeout(() => ref.current?.focus(), 200)
}
}, [isEditing, initialText])
const onEditorStateChange = useCallback((newEditorState: EditorState) => {
// newEditorState.
const newText = newEditorState.getCurrentContent().getPlainText()
onChange && onChange(newText)
setEditorState(newEditorState)
}, [onChange])
const customKeyBindingFn = useCallback((e: React.KeyboardEvent) => {
if (isMentionPopoverOpen || isEmojiPopoverOpen) {
@ -152,30 +186,48 @@ const MarkdownEditorInput = (props: Props): ReactElement => {
return 'editor-blur'
}
if(getDefaultKeyBinding(e) === 'undo'){
return 'editor-undo'
}
if(getDefaultKeyBinding(e) === 'redo'){
return 'editor-redo'
}
return getDefaultKeyBinding(e as any)
}, [isEmojiPopoverOpen, isMentionPopoverOpen])
const handleKeyCommand = useCallback((command: string): DraftHandleValue => {
const handleKeyCommand = useCallback((command: string, currentState: EditorState): DraftHandleValue => {
if (command === 'editor-blur') {
ref.current?.blur()
return 'handled'
}
if(command === 'editor-redo'){
const selectionRemovedState = EditorState.redo(currentState)
onEditorStateChange(EditorState.redo(selectionRemovedState))
return 'handled'
}
if(command === 'editor-undo'){
const selectionRemovedState = EditorState.undo(currentState)
onEditorStateChange(EditorState.undo(selectionRemovedState))
return 'handled'
}
return 'not-handled'
}, [])
const onEditorStateBlur = useCallback(() => {
if (confirmAddUser) {
return
}
const text = editorState.getCurrentContent().getPlainText()
onBlur && onBlur(text)
}, [editorState, onBlur])
const onEditorStateChange = useCallback((newEditorState: EditorState) => {
const newText = newEditorState.getCurrentContent().getPlainText()
onChange && onChange(newText)
setEditorState(newEditorState)
}, [onChange])
const onMentionPopoverOpenChange = useCallback((open: boolean) => {
setIsMentionPopoverOpen(open)
}, [])
@ -192,10 +244,7 @@ const MarkdownEditorInput = (props: Props): ReactElement => {
debouncedLoadSuggestion(value)
}, [suggestions])
let className = 'MarkdownEditorInput'
if (!isEditing) {
className += ' MarkdownEditorInput--IsNotEditing'
}
const className = 'MarkdownEditorInput'
return (
<div
@ -218,11 +267,29 @@ const MarkdownEditorInput = (props: Props): ReactElement => {
suggestions={suggestions}
onSearchChange={onSearchChange}
entryComponent={Entry}
onAddMention={(mention) => {
if (mention.isBoardMember) {
return
}
setConfirmAddUser(mention.user)
}}
/>
<EmojiSuggestions
onOpen={onEmojiPopoverOpen}
onClose={onEmojiPopoverClose}
/>
{confirmAddUser &&
<RootPortal>
<ConfirmAddUserForNotifications
user={confirmAddUser}
onConfirm={addUser}
onClose={() => {
setConfirmAddUser(null)
setEditorState(EditorState.moveSelectionToEnd(editorState))
ref.current?.focus()
}}
/>
</RootPortal>}
</div>
)
}

View File

@ -19,8 +19,10 @@ type Props = {
const PropertyValueElement = (props:Props): JSX.Element => {
const {card, propertyTemplate, readOnly, showEmptyPlaceholder, board} = props
const propertyValue = card.fields.properties[propertyTemplate.id]
let propertyValue = card.fields.properties[propertyTemplate.id]
if(propertyValue === undefined) {
propertyValue = ''
}
const property = propsRegistry.get(propertyTemplate.type)
const Editor = property.Editor
return (

View File

@ -5,6 +5,7 @@
.dialog {
.toolbar {
flex-direction: row-reverse;
padding: 0;
}
}
@ -118,10 +119,11 @@
position: absolute;
left: 13px;
font-size: 18px;
top: 14px;
width: 20px;
height: 20px;
height: 100%;
opacity: 0.48;
display: flex;
align-items: center;
}
.searchQuery {

View File

@ -289,6 +289,23 @@
}
}
.MultiPerson .react-select__value-container--is-multi {
display: block;
white-space: nowrap;
.react-select__multi-value {
background: rgba(var(--center-channel-color-rgb), 0.08);
border-radius: 24px;
display: inline-flex;
color: rgb(var(--center-channel-color-rgb));
.MultiPerson-item,
.react-select__multi-value__label {
color: inherit;
}
}
}
@media screen and (max-width: 768px) {
margin-left: 0 !important;
}

View File

@ -72,8 +72,10 @@
.board-search-icon {
position: absolute;
top: 9px;
left: 10px;
color: rgba(var(--center-channel-color-rgb), 0.64);
display: flex;
align-items: center;
height: 100%;
}
}

View File

@ -11,6 +11,7 @@ import userEvent from '@testing-library/user-event'
import thunk from 'redux-thunk'
import {IUser} from '../user'
import octoClient from '../octoClient'
import {TestBlockFactory} from '../test/testBlockFactory'
import {mockDOM, mockMatchMedia, mockStateStore, wrapDNDIntl} from '../testUtils'
import {Constants} from '../constants'
@ -21,8 +22,10 @@ import Workspace from './workspace'
Object.defineProperty(Constants, 'versionString', {value: '1.0.0'})
jest.useFakeTimers()
jest.mock('../utils')
jest.mock('../octoClient')
jest.mock('draft-js/lib/generateRandomKey', () => () => '123')
const mockedUtils = mocked(Utils, true)
const mockedOctoClient= mocked(octoClient, true)
const board = TestBlockFactory.createBoard()
board.id = 'board1'
board.teamId = 'team-id'
@ -170,6 +173,7 @@ describe('src/components/workspace', () => {
],
},
}
mockedOctoClient.searchTeamUsers.mockResolvedValue(Object.values(state.users.boardUsers))
const store = mockStateStore([thunk], state)
beforeAll(() => {
mockDOM()

View File

@ -198,6 +198,8 @@ class Constants {
static readonly globalTeamId = '0'
static readonly myInsights = 'MY'
static readonly SystemUserID = 'system'
}
export {Constants, Permission}

View File

@ -85,7 +85,7 @@ describe('properties/createdBy', () => {
</ReduxProvider>
)
const {container} = render(component)
const {container} = render(wrapIntl(component))
expect(container).toMatchSnapshot()
})
})

View File

@ -13,6 +13,7 @@ import SelectProperty from './select/property'
import MultiSelectProperty from './multiselect/property'
import DateProperty from './date/property'
import PersonProperty from './person/property'
import MultiPersonProperty from './multiperson/property'
import CheckboxProperty from './checkbox/property'
import UnknownProperty from './unknown/property'
@ -52,6 +53,7 @@ registry.register(new SelectProperty())
registry.register(new MultiSelectProperty())
registry.register(new DateProperty())
registry.register(new PersonProperty())
registry.register(new MultiPersonProperty())
registry.register(new CheckboxProperty())
registry.register(new CreatedTimeProperty())
registry.register(new CreatedByProperty())

View File

@ -0,0 +1,433 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`properties/multiperson not readonly 1`] = `
<div>
<div
class="MultiPerson octo-propertyvalue css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-3-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class="react-select__control css-18140j1-Control"
>
<div
class="react-select__value-container react-select__value-container--is-multi react-select__value-container--has-value css-o7cxt9-ValueContainer"
>
<div
class="css-1rhbuit-multiValue react-select__multi-value"
>
<div
class="css-12jo7m5 react-select__multi-value__label"
>
<div
class="MultiPerson-item"
>
username-1
</div>
</div>
<div
aria-label="Remove [object Object]"
class="css-xb97g8 react-select__multi-value__remove"
role="button"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="14"
viewBox="0 0 20 20"
width="14"
>
<path
d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"
/>
</svg>
</div>
</div>
<div
class="css-1rhbuit-multiValue react-select__multi-value"
>
<div
class="css-12jo7m5 react-select__multi-value__label"
>
<div
class="MultiPerson-item"
>
username-2
</div>
</div>
<div
aria-label="Remove [object Object]"
class="css-xb97g8 react-select__multi-value__remove"
role="button"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="14"
viewBox="0 0 20 20"
width="14"
>
<path
d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"
/>
</svg>
</div>
</div>
<div
class="react-select__input-container css-ox1y69-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="react-select__input"
id="react-select-3-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
class="react-select__indicators css-1hb7zxy-IndicatorsContainer"
>
<div
aria-hidden="true"
class="react-select__indicator react-select__clear-indicator css-tpaeio-indicatorContainer"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
>
<path
d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"
/>
</svg>
</div>
<span
class="react-select__indicator-separator css-43ykx9-indicatorSeparator"
/>
<div
aria-hidden="true"
class="react-select__indicator react-select__dropdown-indicator css-19sxey8-indicatorContainer"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
>
<path
d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
`;
exports[`properties/multiperson not readonly not existing user 1`] = `
<div>
<div
class="MultiPerson octo-propertyvalue css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class="react-select__control css-18140j1-Control"
>
<div
class="react-select__value-container react-select__value-container--is-multi css-433wy7-ValueContainer"
>
<div
class="react-select__placeholder css-14el2xx-placeholder"
id="react-select-2-placeholder"
>
Empty
</div>
<div
class="react-select__input-container css-ox1y69-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-describedby="react-select-2-placeholder"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="react-select__input"
id="react-select-2-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
class="react-select__indicators css-1hb7zxy-IndicatorsContainer"
>
<span
class="react-select__indicator-separator css-43ykx9-indicatorSeparator"
/>
<div
aria-hidden="true"
class="react-select__indicator react-select__dropdown-indicator css-19sxey8-indicatorContainer"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
>
<path
d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
`;
exports[`properties/multiperson readonly view 1`] = `
<div>
<div
class="MultiPerson octo-propertyvalue octo-propertyvalue--readonly"
>
<div
class="MultiPerson-item"
>
username-1
</div>
<div
class="MultiPerson-item"
>
username-2
</div>
</div>
</div>
`;
exports[`properties/multiperson user dropdown open 1`] = `
<div>
<div
class="MultiPerson octo-propertyvalue css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-4-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
>
<span
id="aria-selection"
/>
<span
id="aria-context"
>
option username-3 focused, 3 of 3. 1 result available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.
</span>
</span>
<div
class="react-select__control react-select__control--is-focused react-select__control--menu-is-open css-18140j1-Control"
>
<div
class="react-select__value-container react-select__value-container--is-multi react-select__value-container--has-value css-o7cxt9-ValueContainer"
>
<div
class="css-1rhbuit-multiValue react-select__multi-value"
>
<div
class="css-12jo7m5 react-select__multi-value__label"
>
<div
class="MultiPerson-item"
>
username-1
</div>
</div>
<div
aria-label="Remove [object Object]"
class="css-xb97g8 react-select__multi-value__remove"
role="button"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="14"
viewBox="0 0 20 20"
width="14"
>
<path
d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"
/>
</svg>
</div>
</div>
<div
class="css-1rhbuit-multiValue react-select__multi-value"
>
<div
class="css-12jo7m5 react-select__multi-value__label"
>
<div
class="MultiPerson-item"
>
username-2
</div>
</div>
<div
aria-label="Remove [object Object]"
class="css-xb97g8 react-select__multi-value__remove"
role="button"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="14"
viewBox="0 0 20 20"
width="14"
>
<path
d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"
/>
</svg>
</div>
</div>
<div
class="react-select__input-container css-ox1y69-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-controls="react-select-4-listbox"
aria-expanded="true"
aria-haspopup="true"
aria-owns="react-select-4-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="react-select__input"
id="react-select-4-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
class="react-select__indicators css-1hb7zxy-IndicatorsContainer"
>
<div
aria-hidden="true"
class="react-select__indicator react-select__clear-indicator css-13eygzs-indicatorContainer"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
>
<path
d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"
/>
</svg>
</div>
<span
class="react-select__indicator-separator css-43ykx9-indicatorSeparator"
/>
<div
aria-hidden="true"
class="react-select__indicator react-select__dropdown-indicator css-hl9mox-indicatorContainer"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
>
<path
d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"
/>
</svg>
</div>
</div>
</div>
<div
class="react-select__menu css-10b6da7-menu"
id="react-select-4-listbox"
>
<div
class="react-select__menu-list react-select__menu-list--is-multi css-g29tl0-MenuList"
>
<div
aria-disabled="false"
class="react-select__option react-select__option--is-focused css-1bwtvog-option"
id="react-select-4-option-2"
tabindex="-1"
>
<div
class="MultiPerson-item"
>
username-3
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,97 @@
.MultiPerson {
padding: 4px 8px;
margin-right: 20px;
min-width: 180px;
display: flex;
align-items: center;
border-radius: 4px;
&.readonly {
overflow: hidden;
text-overflow: ellipsis;
min-width: unset;
}
.MultiPerson-item {
display: flex;
align-items: center;
img {
border-radius: 50px;
width: 24px;
height: 24px;
margin-right: 6px;
}
}
.react-select__menu {
background: rgba(var(--center-channel-bg-rgb), 1);
box-shadow: var(--elevation-4);
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
}
.react-select__single-value {
margin: 0;
position: relative;
top: 0;
max-width: 100%;
}
.react-select__value-container--is-multi {
display: inline-flex;
.react-select__multi-value {
background: rgba(var(--center-channel-color-rgb), 0.08);
border-radius: 24px;
display: inline-flex;
color: rgb(var(--center-channel-color-rgb));
.MultiPerson-item,
.react-select__multi-value__label {
color: inherit;
}
}
}
.react-select__multi-value__remove {
font-size: 18px;
color: rgba(var(--center-channel-color-rgb), 0.56);
margin: 6px;
border-radius: 100%;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.26);
}
}
.react-select__option {
display: flex;
align-items: center;
height: 40px;
padding: 0 40px 0 20px;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.08);
}
&:active {
background: rgba(var(--button-bg-rgb), 0.08);
}
&.react-select__option--is-selected {
background: rgba(var(--button-bg-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 1);
}
.MultiPerson-item {
img {
margin-right: 12px;
}
}
}
.react-select__menu-list {
border: 0;
}
}

View File

@ -0,0 +1,185 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {render, waitFor} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {act} from 'react-dom/test-utils'
import userEvent from '@testing-library/user-event'
import {wrapIntl} from '../../testUtils'
import {IPropertyTemplate, Board} from '../../blocks/board'
import {Card} from '../../blocks/card'
import MultiPersonProperty from './property'
import MultiPerson from './multiperson'
describe('properties/multiperson', ()=> {
const mockStore = configureStore([])
const state = {
users: {
boardUsers: {
'user-id-1': {
id: 'user-id-1',
username: 'username-1',
email: 'user-1@example.com',
props: {},
create_at: 1621315184,
update_at: 1621315184,
delete_at: 0,
},
'user-id-2': {
id: 'user-id-2',
username: 'username-2',
email: 'user-2@example.com',
props: {},
create_at: 1621315184,
update_at: 1621315184,
delete_at: 0,
},
'user-id-3': {
id: 'user-id-3',
username: 'username-3',
email: 'user-3@example.com',
props: {},
create_at: 1621315184,
update_at: 1621315184,
delete_at: 0,
},
},
},
clientConfig: {
value: {
teammateNameDisplay: 'username',
},
},
}
test('not readonly not existing user', async () => {
const store = mockStore(state)
const component = wrapIntl(
<ReduxProvider store={store}>
<MultiPerson
property={new MultiPersonProperty()}
propertyValue={['user-id-4']}
readOnly={false}
showEmptyPlaceholder={false}
propertyTemplate={{} as IPropertyTemplate}
board={{} as Board}
card={{} as Card}
/>
</ReduxProvider>,
)
const renderResult = render(component)
const container = await waitFor(() => {
if (!renderResult.container) {
return Promise.reject(new Error('container not found'))
}
return Promise.resolve(renderResult.container)
})
expect(container).toMatchSnapshot()
})
test('not readonly', async () => {
const store = mockStore(state)
const component = wrapIntl(
<ReduxProvider store={store}>
<MultiPerson
property={new MultiPersonProperty()}
propertyValue={['user-id-1', 'user-id-2']}
readOnly={false}
showEmptyPlaceholder={false}
propertyTemplate={{} as IPropertyTemplate}
board={{} as Board}
card={{} as Card}
/>
</ReduxProvider>,
)
const renderResult = render(component)
const container = await waitFor(() => {
if (!renderResult.container) {
return Promise.reject(new Error('container not found'))
}
return Promise.resolve(renderResult.container)
})
expect(container).toMatchSnapshot()
})
test('readonly view', async () => {
const store = mockStore(state)
const component = wrapIntl(
<ReduxProvider store={store}>
<MultiPerson
property={new MultiPersonProperty()}
propertyValue={['user-id-1', 'user-id-2']}
readOnly={true}
showEmptyPlaceholder={false}
propertyTemplate={{} as IPropertyTemplate}
board={{} as Board}
card={{} as Card}
/>
</ReduxProvider>,
)
const renderResult = render(component)
const container = await waitFor(() => {
if (!renderResult.container) {
return Promise.reject(new Error('container not found'))
}
return Promise.resolve(renderResult.container)
})
expect(container).toMatchSnapshot()
})
test('user dropdown open', async () => {
const store = mockStore(state)
const component = wrapIntl(
<ReduxProvider store={store}>
<MultiPerson
property={new MultiPersonProperty()}
propertyValue={['user-id-1', 'user-id-2']}
readOnly={false}
showEmptyPlaceholder={false}
propertyTemplate={{} as IPropertyTemplate}
board={{} as Board}
card={{} as Card}
/>
</ReduxProvider>,
)
const renderResult = render(component)
const container = await waitFor(() => {
if (!renderResult.container) {
return Promise.reject(new Error('container not found'))
}
return Promise.resolve(renderResult.container)
})
if (container) {
// this is the actual element where the click event triggers
// opening of the dropdown
const userProperty = container.querySelector('.MultiPerson > div > div:nth-child(1) > div:nth-child(3) > input')
expect(userProperty).not.toBeNull()
act(() => {
userEvent.click(userProperty as Element)
})
expect(container).toMatchSnapshot()
} else {
throw new Error('container should have been initialized')
}
})
})

View File

@ -0,0 +1,128 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react'
import Select from 'react-select'
import {CSSObject} from '@emotion/serialize'
import {getSelectBaseStyle} from '../../theme'
import {IUser} from '../../user'
import {Utils} from '../../utils'
import mutator from '../../mutator'
import {useAppSelector} from '../../store/hooks'
import { getBoardUsers, getBoardUsersList } from '../../store/users'
import { PropertyProps } from '../types'
import {ClientConfig} from '../../config/clientConfig'
import {getClientConfig} from '../../store/clientConfig'
import './multiperson.scss'
const imageURLForUser = (window as any).Components?.imageURLForUser
const selectStyles = {
...getSelectBaseStyle(),
option: (provided: CSSObject, state: {isFocused: boolean}): CSSObject => ({
...provided,
background: state.isFocused ? 'rgba(var(--center-channel-color-rgb), 0.1)' : 'rgb(var(--center-channel-bg-rgb))',
color: state.isFocused ? 'rgb(var(--center-channel-color-rgb))' : 'rgb(var(--center-channel-color-rgb))',
padding: '8px',
}),
control: (): CSSObject => ({
border: 0,
width: '100%',
margin: '0',
}),
valueContainer: (provided: CSSObject): CSSObject => ({
...provided,
padding: 'unset',
overflow: 'unset',
}),
singleValue: (provided: CSSObject): CSSObject => ({
...provided,
position: 'static',
top: 'unset',
transform: 'unset',
}),
menu: (provided: CSSObject): CSSObject => ({
...provided,
width: 'unset',
background: 'rgb(var(--center-channel-bg-rgb))',
minWidth: '260px',
}),
}
const MultiPerson = (props: PropertyProps) => {
const {card, board, propertyTemplate, propertyValue, readOnly} = props
const clientConfig = useAppSelector<ClientConfig>(getClientConfig)
const boardUsersById = useAppSelector<{[key:string]: IUser}>(getBoardUsers)
const boardUsers = useAppSelector<IUser[]>(getBoardUsersList)
const formatOptionLabel = (user: any) => {
let profileImg
if (imageURLForUser) {
profileImg = imageURLForUser(user.id)
}
return (
<div key={user.id} className='MultiPerson-item'>
{profileImg && (
<img
alt='MultiPerson-avatar'
src={profileImg}
/>
)}
{Utils.getUserDisplayName(user, clientConfig.teammateNameDisplay)}
</div>
)
}
const onChange = useCallback((newValue) => mutator.changePropertyValue(board.id, card, propertyTemplate.id, newValue), [board.id, card, propertyTemplate.id])
let users: IUser[] = []
if(typeof propertyValue === 'string') {
users = [boardUsersById[propertyValue as string]]
} else if(Array.isArray(propertyValue)) {
users = propertyValue.map(id => boardUsersById[id])
}
if (readOnly) {
return (
<div className={`MultiPerson ${props.property.valueClassName(true)}`}>
{users ? users.map(user => formatOptionLabel(user)) : propertyValue}
</div>
)
}
return (
<Select
isMulti
options={boardUsers}
isSearchable={true}
isClearable={true}
placeholder={'Empty'}
className={`MultiPerson ${props.property.valueClassName(props.readOnly)}`}
classNamePrefix={'react-select'}
formatOptionLabel={formatOptionLabel}
styles={selectStyles}
getOptionLabel={(o: IUser) => o.username}
getOptionValue={(a: IUser) => a.id}
value={users}
onChange={(item, action)=> {
if (action.action === 'select-option') {
onChange(item.map(a => a.id) || [])
} else if (action.action === 'clear') {
onChange([])
} else if (action.action === 'remove-value') {
onChange(item.filter(a => a.id !== action.removedValue.id).map(b => b.id) || [])
}
}}
/>
)
}
export default MultiPerson

View File

@ -0,0 +1,12 @@
import {IntlShape} from 'react-intl'
import {PropertyType, PropertyTypeEnum} from '../types'
import MultiPerson from './multiperson'
export default class MultiPersonProperty extends PropertyType {
Editor = MultiPerson
name = 'MultiPerson'
type = 'multiPerson' as PropertyTypeEnum
displayName = (intl:IntlShape) => intl.formatMessage({id: 'PropertyType.MultiPerson', defaultMessage: 'Multi person'})
}

View File

@ -1,18 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react'
import Select from 'react-select'
import React, {useCallback, useState} from 'react'
import Select from 'react-select/async'
import {useIntl} from 'react-intl'
import {CSSObject} from '@emotion/serialize'
import {Utils} from '../../utils'
import {IUser} from '../../user'
import {getBoardUsersList, getBoardUsers} from '../../store/users'
import {BoardMember} from '../../blocks/board'
import {useAppSelector} from '../../store/hooks'
import mutator from '../../mutator'
import {getSelectBaseStyle} from '../../theme'
import {ClientConfig} from '../../config/clientConfig'
import {getClientConfig} from '../../store/clientConfig'
import {useHasPermissions} from '../../hooks/permissions'
import {Permission} from '../../constants'
import client from '../../octoClient'
import ConfirmAddUserForNotifications from '../../components/confirmAddUserForNotifications'
import GuestBadge from '../../widgets/guestBadge'
import {PropertyProps} from '../types'
@ -55,6 +61,7 @@ const selectStyles = {
const Person = (props: PropertyProps): JSX.Element => {
const {card, board, propertyTemplate, propertyValue, readOnly} = props
const [confirmAddUser, setConfirmAddUser] = useState<IUser|null>(null)
const boardUsersById = useAppSelector<{[key:string]: IUser}>(getBoardUsers)
const onChange = useCallback((newValue) => mutator.changePropertyValue(board.id, card, propertyTemplate.id, newValue), [board.id, card, propertyTemplate.id])
@ -62,6 +69,7 @@ const Person = (props: PropertyProps): JSX.Element => {
const me: IUser = boardUsersById[propertyValue as string]
const clientConfig = useAppSelector<ClientConfig>(getClientConfig)
const intl = useIntl()
const formatOptionLabel = (user: IUser) => {
let profileImg
@ -83,6 +91,23 @@ const Person = (props: PropertyProps): JSX.Element => {
)
}
const addUser = useCallback(async (userId: string, role: string) => {
const newMember = {
boardId: board.id,
userId: userId,
roles: role,
schemeAdmin: role === 'Admin',
schemeEditor: role === 'Admin' || role === 'Editor',
schemeCommenter: role === 'Admin' || role === 'Editor' || role === 'Commenter',
schemeViewer: role === 'Admin' || role === 'Editor' || role === 'Commenter' || role === 'Viewer',
} as BoardMember
setConfirmAddUser(null)
await mutator.createBoardMember(board.id, newMember.userId)
await mutator.changePropertyValue(board.id, card, propertyTemplate.id, newMember.userId)
mutator.updateBoardMember(newMember, {...newMember, schemeAdmin: false, schemeEditor: true, schemeCommenter: true, schemeViewer: true})
}, [board, card, propertyTemplate])
if (readOnly) {
return (
<div className={`Person ${props.property.valueClassName(true)}`}>
@ -93,28 +118,66 @@ const Person = (props: PropertyProps): JSX.Element => {
const boardUsers = useAppSelector<IUser[]>(getBoardUsersList)
const allowAddUsers = useHasPermissions(board.teamId, board.id, [Permission.ManageBoardRoles])
const loadOptions = useCallback(async (value: string) => {
if (value === '') {
return boardUsers
}
if (!allowAddUsers) {
return boardUsers.filter((u) => u.username.toLowerCase().includes(value.toLowerCase()))
}
const allUsers = await client.searchTeamUsers(value)
const usersInsideBoard: IUser[] = []
const usersOutsideBoard: IUser[] = []
for (const u of allUsers) {
if (boardUsersById[u.id]) {
usersInsideBoard.push(u)
} else {
usersOutsideBoard.push(u)
}
}
return [
{label: intl.formatMessage({id: 'PersonProperty.board-members', defaultMessage: 'Board members'}), options: usersInsideBoard},
{label: intl.formatMessage({id: 'PersonProperty.non-board-members', defaultMessage: 'Not board members'}), options: usersOutsideBoard},
]
}, [boardUsers, allowAddUsers, boardUsersById])
return (
<Select
options={boardUsers}
isSearchable={true}
isClearable={true}
backspaceRemovesValue={true}
className={`Person ${props.property.valueClassName(props.readOnly)}`}
classNamePrefix={'react-select'}
formatOptionLabel={formatOptionLabel}
styles={selectStyles}
placeholder={'Empty'}
getOptionLabel={(o: IUser) => o.username}
getOptionValue={(a: IUser) => a.id}
value={boardUsersById[propertyValue as string] || null}
onChange={(item, action) => {
if (action.action === 'select-option') {
onChange(item?.id || '')
} else if (action.action === 'clear') {
onChange('')
}
}}
/>
<>
{confirmAddUser &&
<ConfirmAddUserForNotifications
user={confirmAddUser}
onConfirm={addUser}
onClose={() => setConfirmAddUser(null)}
/>}
<Select
loadOptions={loadOptions}
defaultOptions={boardUsers}
isSearchable={true}
isClearable={true}
backspaceRemovesValue={true}
className={`Person ${props.property.valueClassName(props.readOnly)}`}
classNamePrefix={'react-select'}
formatOptionLabel={formatOptionLabel}
styles={selectStyles}
placeholder={'Empty'}
getOptionLabel={(o: IUser) => o.username}
getOptionValue={(a: IUser) => a.id}
value={boardUsersById[propertyValue as string] || null}
onChange={(item, action) => {
if (action.action === 'select-option') {
if (!boardUsersById[item?.id || '']) {
setConfirmAddUser(item)
} else {
onChange(item?.id || '')
}
} else if (action.action === 'clear') {
onChange('')
}
}}
/>
</>
)
}

View File

@ -9,6 +9,7 @@ import configureStore from 'redux-mock-store'
import {createCard} from '../../blocks/card'
import {IUser} from '../../user'
import {wrapIntl} from '../../testUtils'
import {createBoard, IPropertyTemplate} from '../../blocks/board'
@ -64,7 +65,7 @@ describe('properties/updatedBy', () => {
</ReduxProvider>
)
const {container} = render(component)
const {container} = render(wrapIntl(component))
expect(container).toMatchSnapshot()
})
})

View File

@ -298,6 +298,21 @@ function sortCards(cards: Card[], lastCommentByCard: {[key: string]: CommentBloc
bValue = template.options.find((o) => o.id === (Array.isArray(bValue) ? bValue[0] : bValue))?.value || ''
}
if (template.type === 'multiPerson') {
aValue = Array.isArray(aValue) && aValue.length !== 0 && usersById !== {} ? aValue.map((id) => {
if(usersById[id] !== undefined)
return usersById[id].username
return ''
}).toString() : aValue
bValue = Array.isArray(bValue) && bValue.length !== 0 && usersById !== {} ? bValue.map((id) => {
if (usersById[id] !== undefined) {
return usersById[id].username
}
return ''
}).toString() : bValue
}
result = (aValue as string).localeCompare(bValue as string)
}

View File

@ -22,6 +22,7 @@ const contentsSlice = createSlice({
updateContents: (state, action: PayloadAction<ContentBlock[]>) => {
for (const content of action.payload) {
if (content.deleteAt === 0) {
let existsInParent = false
state.contents[content.id] = content
if (!state.contentsByCard[content.parentId]) {
state.contentsByCard[content.parentId] = [content]
@ -30,10 +31,13 @@ const contentsSlice = createSlice({
for (let i = 0; i < state.contentsByCard[content.parentId].length; i++) {
if (state.contentsByCard[content.parentId][i].id === content.id) {
state.contentsByCard[content.parentId][i] = content
return
existsInParent = true
break
}
}
state.contentsByCard[content.parentId].push(content)
if( !existsInParent ){
state.contentsByCard[content.parentId].push(content)
}
} else {
const parentId = state.contents[content.id]?.parentId
if (!state.contentsByCard[parentId]) {

View File

@ -9,6 +9,7 @@
$z-index: (
// key: value
modal-permissions-label: 1000,
board-template-selector: 1000,
notification-box: 1000,
calculation-dropdown: 999,

View File

@ -296,6 +296,31 @@ exports[`widgets/PropertyMenu should match snapshot 1`] = `
class="noicon"
/>
</div>
<div
aria-label="Multi person"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex"
>
<div
class="noicon"
/>
</div>
<div
class="menu-option__content"
>
<div
class="menu-name"
>
Multi person
</div>
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Checkbox"
class="MenuOption TextOption menu-option"