mirror of
https://github.com/mattermost/focalboard.git
synced 2025-02-01 19:14:35 +02:00
Merge branch 'main' into weblate-focalboard-webapp
This commit is contained in:
commit
eb249c6392
25
.github/workflows/lint-server.yml
vendored
25
.github/workflows/lint-server.yml
vendored
@ -7,6 +7,10 @@ on:
|
||||
branches: [ main, release-** ]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
USE_LOCAL_MATTERMOST-SERVER_REPO: true
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
name: plugin
|
||||
@ -16,7 +20,26 @@ jobs:
|
||||
with:
|
||||
go-version: 1.18.1
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
path: "focalboard"
|
||||
- id: "mattermostServer"
|
||||
uses: actions/checkout@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
repository: "mattermost/mattermost-server"
|
||||
fetch-depth: "20"
|
||||
path: "mattermost-server"
|
||||
ref: ${{ env.BRANCH_NAME }}
|
||||
- uses: actions/checkout@v3
|
||||
if: steps.mattermostServer.outcome == 'failure'
|
||||
with:
|
||||
repository: "mattermost/mattermost-server"
|
||||
fetch-depth: "20"
|
||||
path: "mattermost-server"
|
||||
ref : "master"
|
||||
- name: set up golangci-lint
|
||||
run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.46.2
|
||||
- name: lint
|
||||
run: make server-lint
|
||||
run: |
|
||||
cd focalboard
|
||||
make server-lint
|
||||
|
@ -42,17 +42,25 @@ func main() {
|
||||
}
|
||||
|
||||
func makeGoWork(ci bool) string {
|
||||
repos := []string{
|
||||
"mattermost-server",
|
||||
"enterprise",
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("go 1.18\n\n")
|
||||
b.WriteString("use ./mattermost-plugin\n")
|
||||
b.WriteString("use ./server\n")
|
||||
|
||||
for repoIdx := range repos {
|
||||
if isEnvVarTrue(fmt.Sprintf("USE_LOCAL_%s_REPO", strings.ToUpper(repos[repoIdx])), true) {
|
||||
b.WriteString(fmt.Sprintf("use ../%s\n", repos[repoIdx]))
|
||||
}
|
||||
}
|
||||
|
||||
if ci {
|
||||
b.WriteString("use ./linux\n")
|
||||
} else {
|
||||
b.WriteString("use ../mattermost-server\n")
|
||||
b.WriteString("use ../enterprise\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
|
@ -34,7 +34,7 @@ type serviceAPIAdapter struct {
|
||||
func newServiceAPIAdapter(api *boardsProduct) *serviceAPIAdapter {
|
||||
return &serviceAPIAdapter{
|
||||
api: api,
|
||||
ctx: &request.Context{},
|
||||
ctx: request.EmptyContext(api.logger),
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,7 +94,7 @@ func (a *serviceAPIAdapter) GetUserByEmail(email string) (*mm_model.User, error)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) UpdateUser(user *mm_model.User) (*mm_model.User, error) {
|
||||
user, appErr := a.api.userService.UpdateUser(user, true)
|
||||
user, appErr := a.api.userService.UpdateUser(a.ctx, user, true)
|
||||
return user, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
|
@ -75,6 +75,7 @@ func createDelivery(servicesAPI model.ServicesAPI, serverRoot string) (*pluginde
|
||||
Username: botUsername,
|
||||
DisplayName: botDisplayname,
|
||||
Description: botDescription,
|
||||
OwnerId: model.SystemUserID,
|
||||
}
|
||||
botID, err := servicesAPI.EnsureBot(bot)
|
||||
if err != nil {
|
||||
|
@ -12,8 +12,7 @@ import {useWebsockets} from '../../../../webapp/src/hooks/websockets'
|
||||
import octoClient from '../../../../webapp/src/octoClient'
|
||||
import mutator from '../../../../webapp/src/mutator'
|
||||
import {getCurrentTeamId, getAllTeams, Team} from '../../../../webapp/src/store/teams'
|
||||
import {createBoard, BoardsAndBlocks, Board} from '../../../../webapp/src/blocks/board'
|
||||
import {createBoardView} from '../../../../webapp/src/blocks/boardView'
|
||||
import {createBoard, Board} from '../../../../webapp/src/blocks/board'
|
||||
import {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks'
|
||||
import {EmptySearch, EmptyResults} from '../../../../webapp/src/components/searchDialog/searchDialog'
|
||||
import ConfirmationDialog from '../../../../webapp/src/components/confirmationDialogBox'
|
||||
@ -21,11 +20,13 @@ import Dialog from '../../../../webapp/src/components/dialog'
|
||||
import SearchIcon from '../../../../webapp/src/widgets/icons/search'
|
||||
import Button from '../../../../webapp/src/widgets/buttons/button'
|
||||
import {getCurrentLinkToChannel, setLinkToChannel} from '../../../../webapp/src/store/boards'
|
||||
import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../../../webapp/src/telemetry/telemetryClient'
|
||||
import {WSClient} from '../../../../webapp/src/wsclient'
|
||||
import {SuiteWindow} from '../../../../webapp/src/types/index'
|
||||
|
||||
import BoardSelectorItem from './boardSelectorItem'
|
||||
|
||||
const windowAny = (window as SuiteWindow)
|
||||
|
||||
import './boardSelector.scss'
|
||||
|
||||
const BoardSelector = () => {
|
||||
@ -107,27 +108,8 @@ const BoardSelector = () => {
|
||||
}
|
||||
|
||||
const newLinkedBoard = async (): Promise<void> => {
|
||||
const board = {...createBoard(), teamId, channelId: currentChannel}
|
||||
|
||||
const view = createBoardView()
|
||||
view.fields.viewType = 'board'
|
||||
view.parentId = board.id
|
||||
view.boardId = board.id
|
||||
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board view'})
|
||||
|
||||
await mutator.createBoardsAndBlocks(
|
||||
{boards: [board], blocks: [view]},
|
||||
'add linked board',
|
||||
async (bab: BoardsAndBlocks): Promise<void> => {
|
||||
const windowAny: any = window
|
||||
const newBoard = bab.boards[0]
|
||||
// TODO: Maybe create a new event for create linked board
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoard, {board: newBoard?.id})
|
||||
windowAny.WebappUtils.browserHistory.push(`/boards/team/${teamId}/${newBoard.id}`)
|
||||
dispatch(setLinkToChannel(''))
|
||||
},
|
||||
async () => {return},
|
||||
)
|
||||
window.open(`${windowAny.frontendBaseURL}/team/${teamId}/new/${currentChannel}`, '_blank', 'noopener')
|
||||
dispatch(setLinkToChannel(''))
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -13,9 +13,12 @@ import OptionsIcon from '../../../../webapp/src/widgets/icons/options'
|
||||
import DeleteIcon from '../../../../webapp/src/widgets/icons/delete'
|
||||
import Menu from '../../../../webapp/src/widgets/menu'
|
||||
import MenuWrapper from '../../../../webapp/src/widgets/menuWrapper'
|
||||
import {SuiteWindow} from '../../../../webapp/src/types/index'
|
||||
|
||||
import './rhsChannelBoardItem.scss'
|
||||
|
||||
const windowAny = (window as SuiteWindow)
|
||||
|
||||
type Props = {
|
||||
board: Board
|
||||
}
|
||||
@ -30,8 +33,7 @@ const RHSChannelBoardItem = (props: Props) => {
|
||||
}
|
||||
|
||||
const handleBoardClicked = (boardID: string) => {
|
||||
const windowAny: any = window
|
||||
windowAny.WebappUtils.browserHistory.push(`/boards/team/${team.id}/${boardID}`)
|
||||
window.open(`${windowAny.frontendBaseURL}/team/${team.id}/${boardID}`, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
const onUnlinkBoard = async (board: Board) => {
|
||||
|
@ -284,8 +284,9 @@ export default class Plugin {
|
||||
|
||||
const goToFocalboardTemplate = () => {
|
||||
const currentTeam = mmStore.getState().entities.teams.currentTeamId
|
||||
const currentChannel = mmStore.getState().entities.channels.currentChannelId
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ClickChannelIntro, {teamID: currentTeam})
|
||||
window.open(`${windowAny.frontendBaseURL}/team/${currentTeam}`, '_blank', 'noopener')
|
||||
window.open(`${windowAny.frontendBaseURL}/team/${currentTeam}/new/${currentChannel}`, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
if (registry.registerChannelIntroButtonAction) {
|
||||
|
@ -326,7 +326,7 @@ func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingMembership != nil {
|
||||
if existingMembership != nil && !existingMembership.Synthetic {
|
||||
return existingMembership, nil
|
||||
}
|
||||
|
||||
|
107
server/app/boards_test.go
Normal file
107
server/app/boards_test.go
Normal file
@ -0,0 +1,107 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAddMemberToBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
TeamID: "team_id_1",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(nil, nil)
|
||||
|
||||
th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool {
|
||||
p := i.(*model.BoardMember)
|
||||
return p.BoardID == boardID && p.UserID == userID
|
||||
})).Return(&model.BoardMember{
|
||||
BoardID: boardID,
|
||||
}, nil)
|
||||
|
||||
// for WS change broadcast
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, addedBoardMember.BoardID)
|
||||
})
|
||||
|
||||
t.Run("return existing non-synthetic membership if any", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
TeamID: "team_id_1",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
Synthetic: false,
|
||||
}, nil)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, addedBoardMember.BoardID)
|
||||
})
|
||||
|
||||
t.Run("should convert synthetic membership into natural membership", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
TeamID: "team_id_1",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
Synthetic: true,
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool {
|
||||
p := i.(*model.BoardMember)
|
||||
return p.BoardID == boardID && p.UserID == userID
|
||||
})).Return(&model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
Synthetic: false,
|
||||
}, nil)
|
||||
|
||||
// for WS change broadcast
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, addedBoardMember.BoardID)
|
||||
})
|
||||
}
|
@ -669,8 +669,16 @@ func (s *MattermostAuthLayer) GetMemberForBoard(boardID, userID string) (*model.
|
||||
if b.ChannelID != "" {
|
||||
_, err := s.servicesAPI.GetChannelMember(b.ChannelID, userID)
|
||||
if err != nil {
|
||||
var appErr *mmModel.AppError
|
||||
if errors.As(err, &appErr) && appErr.StatusCode == http.StatusNotFound {
|
||||
// Plugin API returns error if channel member doesn't exist.
|
||||
// We're fine if it doesn't exist, so its not an error for us.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.BoardMember{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
|
@ -14,6 +14,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// we group the inserts on batches of 1000 because PostgreSQL
|
||||
// supports a limit of around 64K values (not rows) on an insert
|
||||
// query, so we want to stay safely below.
|
||||
CategoryInsertBatch = 1000
|
||||
|
||||
TemplatesToTeamsMigrationKey = "TemplatesToTeamsMigrationComplete"
|
||||
UniqueIDsMigrationKey = "UniqueIDsMigrationComplete"
|
||||
CategoryUUIDIDMigrationKey = "CategoryUuidIdMigrationComplete"
|
||||
@ -122,6 +127,10 @@ func (s *SQLStore) runUniqueIDsMigration() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCategoryUUIDIDMigration takes care of deriving the categories
|
||||
// from the boards and its memberships. The name references UUID
|
||||
// because of the preexisting purpose of this migration, and has been
|
||||
// preserved for compatibility with already migrated instances.
|
||||
func (s *SQLStore) runCategoryUUIDIDMigration() error {
|
||||
setting, err := s.GetSystemSetting(CategoryUUIDIDMigrationKey)
|
||||
if err != nil {
|
||||
@ -140,159 +149,197 @@ func (s *SQLStore) runCategoryUUIDIDMigration() error {
|
||||
return txErr
|
||||
}
|
||||
|
||||
if err := s.updateCategoryIDs(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.isPlugin {
|
||||
if err := s.createCategories(tx); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("category UUIDs insert categories transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.updateCategoryBlocksIDs(tx); err != nil {
|
||||
return err
|
||||
if err := s.createCategoryBoards(tx); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("category UUIDs insert category boards transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.setSystemSetting(tx, CategoryUUIDIDMigrationKey, strconv.FormatBool(true)); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("category IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
||||
s.logger.Error("category UUIDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
||||
}
|
||||
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("cannot commit category IDs transaction: %w", err)
|
||||
return fmt.Errorf("cannot commit category UUIDs transaction: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("category IDs migration finished successfully")
|
||||
s.logger.Debug("category UUIDs migration finished successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) updateCategoryIDs(db sq.BaseRunner) error {
|
||||
// fetch all category IDs
|
||||
oldCategoryIDs, err := s.getIDs(db, "categories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// map old category ID to new ID
|
||||
categoryIDs := map[string]string{}
|
||||
for _, oldID := range oldCategoryIDs {
|
||||
newID := utils.NewID(utils.IDTypeNone)
|
||||
categoryIDs[oldID] = newID
|
||||
}
|
||||
|
||||
// update for each category ID.
|
||||
// Update the new ID in category table,
|
||||
// and update corresponding rows in category boards table.
|
||||
for oldID, newID := range categoryIDs {
|
||||
if err := s.updateCategoryID(db, oldID, newID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getIDs(db sq.BaseRunner, table string) ([]string, error) {
|
||||
func (s *SQLStore) createCategories(db sq.BaseRunner) error {
|
||||
rows, err := s.getQueryBuilder(db).
|
||||
Select("id").
|
||||
From(s.tablePrefix + table).
|
||||
Select("c.DisplayName, cm.UserId, c.TeamId, cm.ChannelId").
|
||||
From(s.tablePrefix + "boards boards").
|
||||
Join("ChannelMembers cm on boards.channel_id = cm.ChannelId").
|
||||
Join("Channels c on cm.ChannelId = c.id and (c.Type = 'O' or c.Type = 'P')").
|
||||
GroupBy("cm.UserId, c.TeamId, cm.ChannelId, c.DisplayName").
|
||||
Query()
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("getIDs error", mlog.String("table", table), mlog.Err(err))
|
||||
return nil, err
|
||||
s.logger.Error("get boards data error", mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
|
||||
defer s.CloseRows(rows)
|
||||
var categoryIDs []string
|
||||
|
||||
initQuery := func() sq.InsertBuilder {
|
||||
return s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix+"categories").
|
||||
Columns(
|
||||
"id",
|
||||
"name",
|
||||
"user_id",
|
||||
"team_id",
|
||||
"channel_id",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
)
|
||||
}
|
||||
// query will accumulate the insert values until the limit is
|
||||
// reached, and then it will be stored and reset
|
||||
query := initQuery()
|
||||
// queryList stores those queries that already reached the limit
|
||||
// to be run when all the data is processed
|
||||
queryList := []sq.InsertBuilder{}
|
||||
counter := 0
|
||||
now := model.GetMillis()
|
||||
|
||||
for rows.Next() {
|
||||
var id string
|
||||
err := rows.Scan(&id)
|
||||
var displayName string
|
||||
var userID string
|
||||
var teamID string
|
||||
var channelID string
|
||||
|
||||
err := rows.Scan(
|
||||
&displayName,
|
||||
&userID,
|
||||
&teamID,
|
||||
&channelID,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("getIDs scan row error", mlog.String("table", table), mlog.Err(err))
|
||||
return nil, err
|
||||
return fmt.Errorf("cannot scan result while trying to create categories: %w", err)
|
||||
}
|
||||
|
||||
categoryIDs = append(categoryIDs, id)
|
||||
query = query.Values(
|
||||
utils.NewID(utils.IDTypeNone),
|
||||
displayName,
|
||||
userID,
|
||||
teamID,
|
||||
channelID,
|
||||
now,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
counter++
|
||||
if counter%CategoryInsertBatch == 0 {
|
||||
queryList = append(queryList, query)
|
||||
query = initQuery()
|
||||
}
|
||||
}
|
||||
|
||||
return categoryIDs, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) updateCategoryID(db sq.BaseRunner, oldID, newID string) error {
|
||||
// update in category table
|
||||
rows, err := s.getQueryBuilder(db).
|
||||
Update(s.tablePrefix+"categories").
|
||||
Set("id", newID).
|
||||
Where(sq.Eq{"id": oldID}).
|
||||
Query()
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("updateCategoryID update category error", mlog.Err(err))
|
||||
return err
|
||||
if counter%CategoryInsertBatch != 0 {
|
||||
queryList = append(queryList, query)
|
||||
}
|
||||
|
||||
if err = rows.Close(); err != nil {
|
||||
s.logger.Error("updateCategoryID error closing rows after updating categories table IDs", mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// update category boards table
|
||||
|
||||
rows, err = s.getQueryBuilder(db).
|
||||
Update(s.tablePrefix+"category_boards").
|
||||
Set("category_id", newID).
|
||||
Where(sq.Eq{"category_id": oldID}).
|
||||
Query()
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("updateCategoryID update category boards error", mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rows.Close(); err != nil {
|
||||
s.logger.Error("updateCategoryID error closing rows after updating category boards table IDs", mlog.Err(err))
|
||||
return err
|
||||
for _, q := range queryList {
|
||||
if _, err := q.Exec(); err != nil {
|
||||
return fmt.Errorf("cannot create category values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) updateCategoryBlocksIDs(db sq.BaseRunner) error {
|
||||
// fetch all category IDs
|
||||
oldCategoryIDs, err := s.getIDs(db, "category_boards")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// map old category ID to new ID
|
||||
categoryIDs := map[string]string{}
|
||||
for _, oldID := range oldCategoryIDs {
|
||||
newID := utils.NewID(utils.IDTypeNone)
|
||||
categoryIDs[oldID] = newID
|
||||
}
|
||||
|
||||
// update for each category ID.
|
||||
// Update the new ID in category table,
|
||||
// and update corresponding rows in category boards table.
|
||||
for oldID, newID := range categoryIDs {
|
||||
if err := s.updateCategoryBlocksID(db, oldID, newID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) updateCategoryBlocksID(db sq.BaseRunner, oldID, newID string) error {
|
||||
// update in category table
|
||||
func (s *SQLStore) createCategoryBoards(db sq.BaseRunner) error {
|
||||
rows, err := s.getQueryBuilder(db).
|
||||
Update(s.tablePrefix+"category_boards").
|
||||
Set("id", newID).
|
||||
Where(sq.Eq{"id": oldID}).
|
||||
Select("categories.user_id, categories.id, boards.id").
|
||||
From(s.tablePrefix + "categories categories").
|
||||
Join(s.tablePrefix + "boards boards on categories.channel_id = boards.channel_id AND boards.is_template = false").
|
||||
Query()
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("updateCategoryBlocksID update category error", mlog.Err(err))
|
||||
s.logger.Error("get categories data error", mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
initQuery := func() sq.InsertBuilder {
|
||||
return s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix+"category_boards").
|
||||
Columns(
|
||||
"id",
|
||||
"user_id",
|
||||
"category_id",
|
||||
"board_id",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
)
|
||||
}
|
||||
// query will accumulate the insert values until the limit is
|
||||
// reached, and then it will be stored and reset
|
||||
query := initQuery()
|
||||
// queryList stores those queries that already reached the limit
|
||||
// to be run when all the data is processed
|
||||
queryList := []sq.InsertBuilder{}
|
||||
counter := 0
|
||||
now := model.GetMillis()
|
||||
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var categoryID string
|
||||
var boardID string
|
||||
|
||||
err := rows.Scan(
|
||||
&userID,
|
||||
&categoryID,
|
||||
&boardID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot scan result while trying to create category boards: %w", err)
|
||||
}
|
||||
|
||||
query = query.Values(
|
||||
utils.NewID(utils.IDTypeNone),
|
||||
userID,
|
||||
categoryID,
|
||||
boardID,
|
||||
now,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
counter++
|
||||
if counter%CategoryInsertBatch == 0 {
|
||||
queryList = append(queryList, query)
|
||||
query = initQuery()
|
||||
}
|
||||
}
|
||||
|
||||
if counter%CategoryInsertBatch != 0 {
|
||||
queryList = append(queryList, query)
|
||||
}
|
||||
|
||||
for _, q := range queryList {
|
||||
if _, err := q.Exec(); err != nil {
|
||||
return fmt.Errorf("cannot create category boards values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -302,7 +302,7 @@ INSERT INTO {{.prefix}}board_members (
|
||||
SELECT B.Id, CM.UserId, CM.Roles, TRUE, TRUE, FALSE, FALSE
|
||||
FROM {{.prefix}}boards AS B
|
||||
INNER JOIN ChannelMembers as CM ON CM.ChannelId=B.channel_id
|
||||
WHERE CM.SchemeAdmin=True
|
||||
WHERE CM.SchemeAdmin=True OR (CM.UserId=B.created_by)
|
||||
);
|
||||
{{end}}
|
||||
|
||||
|
@ -11,36 +11,3 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}categories (
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
CREATE INDEX idx_categories_user_id_team_id ON {{.prefix}}categories(user_id, team_id);
|
||||
|
||||
{{if .plugin}}
|
||||
INSERT INTO {{.prefix}}categories(
|
||||
id,
|
||||
name,
|
||||
user_id,
|
||||
team_id,
|
||||
channel_id,
|
||||
create_at,
|
||||
update_at,
|
||||
delete_at
|
||||
)
|
||||
SELECT
|
||||
{{ if .postgres }}
|
||||
REPLACE(uuid_in(md5(random()::text || clock_timestamp()::text)::cstring)::varchar, '-', ''),
|
||||
{{ end }}
|
||||
{{ if .mysql }}
|
||||
UUID(),
|
||||
{{ end }}
|
||||
c.DisplayName,
|
||||
cm.UserId,
|
||||
c.TeamId,
|
||||
cm.ChannelId,
|
||||
{{if .postgres}}(extract(epoch from now())*1000)::bigint,{{end}}
|
||||
{{if .mysql}}UNIX_TIMESTAMP() * 1000,{{end}}
|
||||
0,
|
||||
0
|
||||
FROM
|
||||
{{.prefix}}boards boards
|
||||
JOIN ChannelMembers cm on boards.channel_id = cm.ChannelId
|
||||
JOIN Channels c on cm.ChannelId = c.id and (c.Type = 'O' or c.Type = 'P')
|
||||
GROUP BY cm.UserId, c.TeamId, cm.ChannelId, c.DisplayName;
|
||||
{{end}}
|
||||
|
@ -7,28 +7,6 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}category_boards (
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
PRIMARY KEY (id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
CREATE INDEX idx_categoryboards_category_id ON {{.prefix}}category_boards(category_id);
|
||||
|
||||
{{if .plugin}}
|
||||
INSERT INTO {{.prefix}}category_boards(id, user_id, category_id, board_id, create_at, update_at, delete_at)
|
||||
SELECT
|
||||
{{ if .postgres }}
|
||||
REPLACE(uuid_in(md5(random()::text || clock_timestamp()::text)::cstring)::varchar, '-', ''),
|
||||
{{ end }}
|
||||
{{ if .mysql }}
|
||||
UUID(),
|
||||
{{ end }}
|
||||
{{.prefix}}categories.user_id,
|
||||
{{.prefix}}categories.id,
|
||||
{{.prefix}}boards.id,
|
||||
{{if .postgres}}(extract(epoch from now())*1000)::bigint,{{end}}
|
||||
{{if .mysql}}UNIX_TIMESTAMP() * 1000,{{end}}
|
||||
0,
|
||||
0
|
||||
FROM
|
||||
{{.prefix}}categories
|
||||
JOIN {{.prefix}}boards ON {{.prefix}}categories.channel_id = {{.prefix}}boards.channel_id
|
||||
AND {{.prefix}}boards.is_template = false;
|
||||
{{end}}
|
||||
|
@ -19,10 +19,10 @@
|
||||
"BoardTemplateSelector.add-template": "New template",
|
||||
"BoardTemplateSelector.create-empty-board": "Create empty board",
|
||||
"BoardTemplateSelector.delete-template": "Delete",
|
||||
"BoardTemplateSelector.description": "Choose a template to help you get started. Easily customize the template to fit your needs, or create an empty board to start from scratch.",
|
||||
"BoardTemplateSelector.description": "Add a board to the sidebar using any of the templates defined below or start from scratch.",
|
||||
"BoardTemplateSelector.edit-template": "Edit",
|
||||
"BoardTemplateSelector.plugin.no-content-description": "Add a board to the sidebar using any of the templates defined below or start from scratch.{lineBreak} Members of \"{teamName}\" will have access to boards created here.",
|
||||
"BoardTemplateSelector.plugin.no-content-title": "Create a Board in {teamName}",
|
||||
"BoardTemplateSelector.plugin.no-content-description": "Add a board to the sidebar using any of the templates defined below or start from scratch.",
|
||||
"BoardTemplateSelector.plugin.no-content-title": "Create a board",
|
||||
"BoardTemplateSelector.title": "Create a board",
|
||||
"BoardTemplateSelector.use-this-template": "Use this template",
|
||||
"BoardsSwitcher.Title": "Find Boards",
|
||||
@ -344,6 +344,10 @@
|
||||
"register.login-button": "or log in if you already have an account",
|
||||
"register.signup-title": "Sign up for your account",
|
||||
"rhs-boards.add": "Add",
|
||||
"rhs-boards.dm": "DM",
|
||||
"rhs-boards.gm": "GM",
|
||||
"rhs-boards.header.dm": "this Direct Message",
|
||||
"rhs-boards.header.gm": "this Group Message",
|
||||
"rhs-boards.last-update-at": "Last update at: {datetime}",
|
||||
"rhs-boards.link-boards-to-channel": "Link boards to {channelName}",
|
||||
"rhs-boards.linked-boards": "Linked boards",
|
||||
@ -351,16 +355,15 @@
|
||||
"rhs-boards.no-boards-linked-to-channel-description": "Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view.",
|
||||
"rhs-boards.unlink-board": "Unlink board",
|
||||
"rhs-channel-boards-header.title": "Boards",
|
||||
"rhs-boards.dm": "DM",
|
||||
"rhs-boards.header.dm": "this Direct Message",
|
||||
"rhs-boards.gm": "GM",
|
||||
"rhs-boards.header.gm": "this Group Message",
|
||||
"share-board.publish": "Publish",
|
||||
"share-board.share": "Share",
|
||||
"shareBoard.channels-select-group": "Channels",
|
||||
"shareBoard.confirm-link-public-channel": "You're adding a public channel",
|
||||
"shareBoard.confirm-link-public-channel-button": "Yes, add public channel",
|
||||
"shareBoard.confirm-link-public-channel-subtext": "Anyone who joins that public channel will now get “Editor” access to the board, are you sure you want to proceed?",
|
||||
"shareBoard.confirm-unlink.body": "When you unlink a channel from a board, all members of the channel (existing and new) will loose access to it unless they are given permission separately. {lineBreak} Are you sure you want to unlink it?",
|
||||
"shareBoard.confirm-unlink.confirmBtnText": "Yes, unlink",
|
||||
"shareBoard.confirm-unlink.title": "Unlink channel from board",
|
||||
"shareBoard.lastAdmin": "Boards must have at least one Administrator",
|
||||
"shareBoard.members-select-group": "Members",
|
||||
"tutorial_tip.finish_tour": "Done",
|
||||
@ -368,4 +371,4 @@
|
||||
"tutorial_tip.ok": "Next",
|
||||
"tutorial_tip.out": "Opt out of these tips.",
|
||||
"tutorial_tip.seen": "Seen this before?"
|
||||
}
|
||||
}
|
@ -29,7 +29,7 @@ exports[`components/boardTemplateSelector/boardTemplateSelector a focalboard Plu
|
||||
<p
|
||||
class="description"
|
||||
>
|
||||
Choose a template to help you get started. Easily customize the template to fit your needs, or create an empty board to start from scratch.
|
||||
Add a board to the sidebar using any of the templates defined below or start from scratch.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@ -307,7 +307,7 @@ exports[`components/boardTemplateSelector/boardTemplateSelector a focalboard Plu
|
||||
<p
|
||||
class="description"
|
||||
>
|
||||
Choose a template to help you get started. Easily customize the template to fit your needs, or create an empty board to start from scratch.
|
||||
Add a board to the sidebar using any of the templates defined below or start from scratch.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@ -456,7 +456,7 @@ exports[`components/boardTemplateSelector/boardTemplateSelector not a focalboard
|
||||
<p
|
||||
class="description"
|
||||
>
|
||||
Choose a template to help you get started. Easily customize the template to fit your needs, or create an empty board to start from scratch.
|
||||
Add a board to the sidebar using any of the templates defined below or start from scratch.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
@ -81,6 +81,7 @@
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
height: 512px;
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
|
@ -15,6 +15,7 @@ import {MemoryRouter, Router} from 'react-router-dom'
|
||||
import Mutator from '../../mutator'
|
||||
import {Utils} from '../../utils'
|
||||
import {Team} from '../../store/teams'
|
||||
import {createBoard, Board} from '../../blocks/board'
|
||||
import {IUser} from '../../user'
|
||||
import {mockDOM, mockStateStore, wrapDNDIntl} from '../../testUtils'
|
||||
|
||||
@ -227,17 +228,22 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
|
||||
userEvent.click(divNewTemplate!)
|
||||
expect(mockedMutator.addEmptyBoardTemplate).toBeCalledTimes(1)
|
||||
})
|
||||
test('return BoardTemplateSelector and click empty board', () => {
|
||||
test('return BoardTemplateSelector and click empty board', async () => {
|
||||
const newBoard = createBoard({id: 'new-board'} as Board)
|
||||
mockedMutator.addEmptyBoard.mockResolvedValue({boards: [newBoard], blocks: []})
|
||||
|
||||
render(wrapDNDIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<BoardTemplateSelector onClose={jest.fn()}/>
|
||||
</ReduxProvider>
|
||||
,
|
||||
), {wrapper: MemoryRouter})
|
||||
|
||||
const divEmptyboard = screen.getByText('Create empty board').parentElement
|
||||
expect(divEmptyboard).not.toBeNull()
|
||||
userEvent.click(divEmptyboard!)
|
||||
expect(mockedMutator.addEmptyBoard).toBeCalledTimes(1)
|
||||
await waitFor(() => expect(mockedMutator.updateBoard).toBeCalledWith(newBoard, newBoard, 'linked channel'))
|
||||
})
|
||||
test('return BoardTemplateSelector and click delete template icon', async () => {
|
||||
const root = document.createElement('div')
|
||||
@ -279,6 +285,9 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
|
||||
userEvent.click(editIcon!)
|
||||
})
|
||||
test('return BoardTemplateSelector and click to add board from template', async () => {
|
||||
const newBoard = createBoard({id: 'new-board'} as Board)
|
||||
mockedMutator.addBoardFromTemplate.mockResolvedValue({boards: [newBoard], blocks: []})
|
||||
|
||||
render(wrapDNDIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<BoardTemplateSelector onClose={jest.fn()}/>
|
||||
@ -300,8 +309,44 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
|
||||
|
||||
await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledTimes(1))
|
||||
await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledWith(team1.id, expect.anything(), expect.anything(), expect.anything(), '1', team1.id))
|
||||
await waitFor(() => expect(mockedMutator.updateBoard).toBeCalledWith(newBoard, newBoard, 'linked channel'))
|
||||
})
|
||||
|
||||
test('return BoardTemplateSelector and click to add board from template with channelId', async () => {
|
||||
const newBoard = createBoard({id: 'new-board'} as Board)
|
||||
mockedMutator.addBoardFromTemplate.mockResolvedValue({boards: [newBoard], blocks: []})
|
||||
|
||||
render(wrapDNDIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<BoardTemplateSelector
|
||||
onClose={jest.fn()}
|
||||
channelId='test-channel'
|
||||
/>
|
||||
</ReduxProvider>
|
||||
,
|
||||
), {wrapper: MemoryRouter})
|
||||
const divBoardToSelect = screen.getByText(template1Title).parentElement
|
||||
expect(divBoardToSelect).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
userEvent.click(divBoardToSelect!)
|
||||
})
|
||||
|
||||
const useTemplateButton = screen.getByText('Use this template').parentElement
|
||||
expect(useTemplateButton).not.toBeNull()
|
||||
act(() => {
|
||||
userEvent.click(useTemplateButton!)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledTimes(1))
|
||||
await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledWith(team1.id, expect.anything(), expect.anything(), expect.anything(), '1', team1.id))
|
||||
await waitFor(() => expect(mockedMutator.updateBoard).toBeCalledWith({...newBoard, channelId: 'test-channel'}, newBoard, 'linked channel'))
|
||||
})
|
||||
|
||||
test('return BoardTemplateSelector and click to add board from global template', async () => {
|
||||
const newBoard = createBoard({id: 'new-board'} as Board)
|
||||
mockedMutator.addBoardFromTemplate.mockResolvedValue({boards: [newBoard], blocks: []})
|
||||
|
||||
render(wrapDNDIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<BoardTemplateSelector onClose={jest.fn()}/>
|
||||
@ -323,8 +368,12 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
|
||||
await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledTimes(1))
|
||||
await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledWith(team1.id, expect.anything(), expect.anything(), expect.anything(), 'global-1', team1.id))
|
||||
await waitFor(() => expect(mockedTelemetry.trackEvent).toBeCalledWith('boards', 'createBoardViaTemplate', {boardTemplateId: 'template_id_global'}))
|
||||
await waitFor(() => expect(mockedMutator.updateBoard).toBeCalledWith(newBoard, newBoard, 'linked channel'))
|
||||
})
|
||||
test('should start product tour on choosing welcome template', async () => {
|
||||
const newBoard = createBoard({id: 'new-board'} as Board)
|
||||
mockedMutator.addBoardFromTemplate.mockResolvedValue({boards: [newBoard], blocks: []})
|
||||
|
||||
render(wrapDNDIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<BoardTemplateSelector onClose={jest.fn()}/>
|
||||
@ -347,6 +396,7 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
|
||||
await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledTimes(1))
|
||||
await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledWith(team1.id, expect.anything(), expect.anything(), expect.anything(), '2', team1.id))
|
||||
await waitFor(() => expect(mockedTelemetry.trackEvent).toBeCalledWith('boards', 'createBoardViaTemplate', {boardTemplateId: 'template_id_2'}))
|
||||
await waitFor(() => expect(mockedMutator.updateBoard).toBeCalledWith(newBoard, newBoard, 'linked channel'))
|
||||
expect(mockedOctoClient.patchUserConfig).toBeCalledWith('user-id-1', {
|
||||
updatedFields: {
|
||||
'focalboard_onboardingTourStarted': '1',
|
||||
|
@ -34,6 +34,7 @@ type Props = {
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
onClose?: () => void
|
||||
channelId?: string
|
||||
}
|
||||
|
||||
const BoardTemplateSelector = (props: Props) => {
|
||||
@ -99,10 +100,12 @@ const BoardTemplateSelector = (props: Props) => {
|
||||
|
||||
const handleUseTemplate = async () => {
|
||||
if (activeTemplate.teamId === '0') {
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoardViaTemplate, {boardTemplateId: activeTemplate.properties.trackingTemplateId as string})
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoardViaTemplate, {boardTemplateId: activeTemplate.properties.trackingTemplateId as string, channelID: props.channelId})
|
||||
}
|
||||
|
||||
await mutator.addBoardFromTemplate(currentTeam?.id || Constants.globalTeamId, intl, showBoard, () => showBoard(currentBoardId), activeTemplate.id, currentTeam?.id)
|
||||
const boardsAndBlocks = await mutator.addBoardFromTemplate(currentTeam?.id || Constants.globalTeamId, intl, showBoard, () => showBoard(currentBoardId), activeTemplate.id, currentTeam?.id)
|
||||
const board = boardsAndBlocks.boards[0]
|
||||
await mutator.updateBoard({...board, channelId: props.channelId || ''}, board, 'linked channel')
|
||||
if (activeTemplate.title === OnboardingBoardTitle) {
|
||||
resetTour()
|
||||
}
|
||||
@ -144,7 +147,7 @@ const BoardTemplateSelector = (props: Props) => {
|
||||
{description || (
|
||||
<FormattedMessage
|
||||
id='BoardTemplateSelector.description'
|
||||
defaultMessage='Choose a template to help you get started. Easily customize the template to fit your needs, or create an empty board to start from scratch.'
|
||||
defaultMessage='Add a board to the sidebar using any of the templates defined below or start from scratch.'
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
@ -193,7 +196,11 @@ const BoardTemplateSelector = (props: Props) => {
|
||||
filled={false}
|
||||
emphasis={'secondary'}
|
||||
size={'medium'}
|
||||
onClick={() => mutator.addEmptyBoard(currentTeam?.id || '', intl, showBoard, () => showBoard(currentBoardId))}
|
||||
onClick={async () => {
|
||||
const boardsAndBlocks = await mutator.addEmptyBoard(currentTeam?.id || '', intl, showBoard, () => showBoard(currentBoardId))
|
||||
const board = boardsAndBlocks.boards[0]
|
||||
await mutator.updateBoard({...board, channelId: props.channelId || ''}, board, 'linked channel')
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='BoardTemplateSelector.create-empty-board'
|
||||
|
@ -40,12 +40,21 @@ const BoardsSwitcher = (props: Props): JSX.Element => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscKeyPress = (e: KeyboardEvent) => {
|
||||
if (Utils.isKeyPressed(e, Constants.keyCodes.ESC)) {
|
||||
e.preventDefault()
|
||||
setShowSwitcher(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleQuickSwitchKeyPress)
|
||||
document.addEventListener('keydown', handleEscKeyPress)
|
||||
|
||||
// cleanup function
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleQuickSwitchKeyPress)
|
||||
document.removeEventListener('keydown', handleEscKeyPress)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {ReactNode} from 'react'
|
||||
import React, {ReactNode, useRef, createRef, useState, useEffect, MutableRefObject} from 'react'
|
||||
|
||||
import './boardSwitcherDialog.scss'
|
||||
import {useIntl} from 'react-intl'
|
||||
@ -14,13 +14,20 @@ import LockOutline from '../../widgets/icons/lockOutline'
|
||||
import {useAppSelector} from '../../store/hooks'
|
||||
import {getAllTeams, getCurrentTeam, Team} from '../../store/teams'
|
||||
import {getMe} from '../../store/users'
|
||||
import {Utils} from '../../utils'
|
||||
import {BoardTypeOpen, BoardTypePrivate} from '../../blocks/board'
|
||||
import { Constants } from '../../constants'
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const BoardSwitcherDialog = (props: Props): JSX.Element => {
|
||||
const [selected, setSelected] = useState<number>(-1)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [refs, setRefs] = useState<MutableRefObject<any>>(useRef([]))
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [IDs, setIDs] = useState<any>({})
|
||||
const intl = useIntl()
|
||||
const team = useAppSelector(getCurrentTeam)
|
||||
const me = useAppSelector(getMe)
|
||||
@ -42,7 +49,7 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => {
|
||||
if (!me) {
|
||||
return
|
||||
}
|
||||
const newPath = generatePath(match.path, {...match.params, teamId, boardId, viewId: undefined})
|
||||
const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, teamId, boardId, viewId: undefined})
|
||||
history.push(newPath)
|
||||
props.onClose()
|
||||
}
|
||||
@ -57,14 +64,22 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => {
|
||||
|
||||
const items = await octoClient.searchAll(query)
|
||||
const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})
|
||||
return items.map((item) => {
|
||||
refs.current = items.map((_, i) => refs.current[i] ?? createRef())
|
||||
setRefs(refs)
|
||||
return items.map((item, i) => {
|
||||
const resultTitle = item.title || untitledBoardTitle
|
||||
const teamTitle = teamsById[item.teamId].title
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setIDs((prevIDs: any) => ({
|
||||
...prevIDs,
|
||||
[i]: [item.teamId, item.id]
|
||||
}))
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className='blockSearchResult'
|
||||
onClick={() => selectBoard(item.teamId, item.id)}
|
||||
ref={refs.current[i]}
|
||||
>
|
||||
{item.type === BoardTypeOpen && <Globe/>}
|
||||
{item.type === BoardTypePrivate && <LockOutline/>}
|
||||
@ -75,12 +90,34 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleEnterKeyPress = (e: KeyboardEvent) => {
|
||||
if (Utils.isKeyPressed(e, Constants.keyCodes.ENTER) && selected > -1) {
|
||||
e.preventDefault()
|
||||
const [teamId, id] = IDs[selected]
|
||||
selectBoard(teamId, id)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selected >= 0)
|
||||
refs.current[selected].current.parentElement.focus()
|
||||
|
||||
document.addEventListener('keydown', handleEnterKeyPress)
|
||||
|
||||
// cleanup function
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEnterKeyPress)
|
||||
}
|
||||
}, [selected, refs, IDs])
|
||||
|
||||
return (
|
||||
<SearchDialog
|
||||
onClose={props.onClose}
|
||||
title={title}
|
||||
subTitle={subTitle}
|
||||
searchHandler={searchHandler}
|
||||
selected={selected}
|
||||
setSelected={(n: number) => setSelected(n)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -58,12 +58,13 @@
|
||||
padding: 0 24px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
&.freesize {
|
||||
height: unset;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {ReactNode, useMemo, useState} from 'react'
|
||||
import React, {ReactNode, useEffect, useMemo, useState} from 'react'
|
||||
|
||||
import './searchDialog.scss'
|
||||
import {FormattedMessage} from 'react-intl'
|
||||
@ -10,6 +10,7 @@ import {debounce} from 'lodash'
|
||||
import Dialog from '../dialog'
|
||||
import {Utils} from '../../utils'
|
||||
import Search from '../../widgets/icons/search'
|
||||
import { Constants } from '../../constants'
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
@ -17,9 +18,11 @@ type Props = {
|
||||
subTitle?: string | ReactNode
|
||||
searchHandler: (query: string) => Promise<Array<ReactNode>>
|
||||
initialData?: Array<ReactNode>
|
||||
selected: number
|
||||
setSelected: (n: number) => void
|
||||
}
|
||||
|
||||
export const EmptySearch = () => (
|
||||
export const EmptySearch = (): JSX.Element => (
|
||||
<div className='noResults introScreen'>
|
||||
<div className='iconWrapper'>
|
||||
<Search/>
|
||||
@ -33,7 +36,7 @@ export const EmptySearch = () => (
|
||||
</div>
|
||||
)
|
||||
|
||||
export const EmptyResults = (props: {query: string}) => (
|
||||
export const EmptyResults = (props: {query: string}): JSX.Element => (
|
||||
<div className='noResults'>
|
||||
<div className='iconWrapper'>
|
||||
<Search/>
|
||||
@ -57,12 +60,14 @@ export const EmptyResults = (props: {query: string}) => (
|
||||
)
|
||||
|
||||
const SearchDialog = (props: Props): JSX.Element => {
|
||||
const {selected, setSelected} = props
|
||||
const [results, setResults] = useState<Array<ReactNode>>(props.initialData || [])
|
||||
const [isSearching, setIsSearching] = useState<boolean>(false)
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
|
||||
const searchHandler = async (query: string): Promise<void> => {
|
||||
setIsSearching(true)
|
||||
setSelected(-1)
|
||||
setSearchQuery(query)
|
||||
const searchResults = await props.searchHandler(query)
|
||||
setResults(searchResults)
|
||||
@ -73,6 +78,29 @@ const SearchDialog = (props: Props): JSX.Element => {
|
||||
|
||||
const emptyResult = results.length === 0 && !isSearching && searchQuery
|
||||
|
||||
const handleUpDownKeyPress = (e: KeyboardEvent) => {
|
||||
if (Utils.isKeyPressed(e, Constants.keyCodes.DOWN)) {
|
||||
e.preventDefault()
|
||||
if (results.length > 0)
|
||||
setSelected(((selected + 1) < results.length) ? (selected + 1) : selected)
|
||||
}
|
||||
|
||||
if (Utils.isKeyPressed(e, Constants.keyCodes.UP)) {
|
||||
e.preventDefault()
|
||||
if (results.length > 0)
|
||||
setSelected(((selected - 1) > -1) ? (selected - 1) : selected)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleUpDownKeyPress)
|
||||
|
||||
// cleanup function
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleUpDownKeyPress)
|
||||
}
|
||||
}, [results, selected])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className='BoardSwitcherDialog'
|
||||
@ -101,6 +129,7 @@ const SearchDialog = (props: Props): JSX.Element => {
|
||||
<div
|
||||
key={Utils.uuid()}
|
||||
className='searchResult'
|
||||
tabIndex={-1}
|
||||
>
|
||||
{result}
|
||||
</div>
|
||||
|
@ -1,5 +1,255 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`src/components/shareBoard/shareBoard confirm unlinking linked channel 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Dialog dialog-back ShareBoardDialog"
|
||||
>
|
||||
<div
|
||||
class="backdrop"
|
||||
/>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
title="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="toolbar--right"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text-heading5"
|
||||
>
|
||||
Share Board
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="share-input__container"
|
||||
>
|
||||
<div
|
||||
class="share-input"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-magnify MagnifyIcon"
|
||||
/>
|
||||
<div
|
||||
class="userSearchInput css-b62m3t-container"
|
||||
>
|
||||
<span
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
id="react-select-12-live-region"
|
||||
/>
|
||||
<span
|
||||
aria-atomic="false"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
/>
|
||||
<div
|
||||
class=" css-1wmrr75-Control"
|
||||
>
|
||||
<div
|
||||
class=" css-30zlo3-ValueContainer"
|
||||
>
|
||||
<div
|
||||
class=" css-14el2xx-placeholder"
|
||||
id="react-select-12-placeholder"
|
||||
>
|
||||
Search for people
|
||||
</div>
|
||||
<div
|
||||
class=" css-ox1y69-Input"
|
||||
data-value=""
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-describedby="react-select-12-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
class=""
|
||||
id="react-select-12-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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="user-items"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<div
|
||||
class="user-item__content"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-mattermost user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Everyone at Test Team Team
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="user-item__button"
|
||||
>
|
||||
None
|
||||
<i
|
||||
class="CompassIcon icon-chevron-down CompassIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="user-item channel-item"
|
||||
>
|
||||
<div
|
||||
class="user-item__content"
|
||||
>
|
||||
<span
|
||||
class="user-item__img"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-lock-outline LockOutlineIcon"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Dunder Mifflin Party Planing Committee
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="user-item__button"
|
||||
>
|
||||
Editor
|
||||
<i
|
||||
class="CompassIcon icon-chevron-down CompassIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="tabs-container"
|
||||
>
|
||||
<button
|
||||
class="tab-item tab-item--active"
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
class="tab-item false"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="tabs-content"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="d-flex justify-content-between"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column"
|
||||
>
|
||||
<div
|
||||
class="text-heading2"
|
||||
>
|
||||
Share internally
|
||||
</div>
|
||||
<div
|
||||
class="text-light"
|
||||
>
|
||||
Users who have permissions will be able to use this link.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="d-flex justify-content-between tabs-inputs"
|
||||
>
|
||||
<div
|
||||
class="d-flex input-container"
|
||||
>
|
||||
<a
|
||||
class="shareUrl"
|
||||
href="http://localhost/1/1"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
http://localhost/1/1
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
title="Copy internal link"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-content-copy CompassIcon"
|
||||
/>
|
||||
<span>
|
||||
Copy link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy link 1`] = `
|
||||
<div>
|
||||
<div
|
||||
@ -790,6 +1040,44 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="user-item channel-item"
|
||||
>
|
||||
<div
|
||||
class="user-item__content"
|
||||
>
|
||||
<span
|
||||
class="user-item__img"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-lock-outline LockOutlineIcon"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Dunder Mifflin Party Planing Committee
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="user-item__button"
|
||||
>
|
||||
Editor
|
||||
<i
|
||||
class="CompassIcon icon-chevron-down CompassIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="tabs-content"
|
||||
@ -1220,6 +1508,44 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="user-item channel-item"
|
||||
>
|
||||
<div
|
||||
class="user-item__content"
|
||||
>
|
||||
<span
|
||||
class="user-item__img"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-lock-outline LockOutlineIcon"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Dunder Mifflin Party Planing Committee
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="user-item__button"
|
||||
>
|
||||
Editor
|
||||
<i
|
||||
class="CompassIcon icon-chevron-down CompassIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="tabs-content"
|
||||
@ -1418,6 +1744,44 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="user-item channel-item"
|
||||
>
|
||||
<div
|
||||
class="user-item__content"
|
||||
>
|
||||
<span
|
||||
class="user-item__img"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-lock-outline LockOutlineIcon"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Dunder Mifflin Party Planing Committee
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="user-item__button"
|
||||
>
|
||||
Editor
|
||||
<i
|
||||
class="CompassIcon icon-chevron-down CompassIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="tabs-content"
|
||||
@ -1848,6 +2212,44 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="user-item channel-item"
|
||||
>
|
||||
<div
|
||||
class="user-item__content"
|
||||
>
|
||||
<span
|
||||
class="user-item__img"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-lock-outline LockOutlineIcon"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Dunder Mifflin Party Planing Committee
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="user-item__button"
|
||||
>
|
||||
Editor
|
||||
<i
|
||||
class="CompassIcon icon-chevron-down CompassIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="tabs-content"
|
||||
|
@ -19,16 +19,19 @@ import PrivateIcon from '../../widgets/icons/lockOutline'
|
||||
import PublicIcon from '../../widgets/icons/globe'
|
||||
import DeleteIcon from '../../widgets/icons/delete'
|
||||
import CompassIcon from '../../widgets/icons/compassIcon'
|
||||
import ConfirmationDialogBox from "../confirmationDialogBox"
|
||||
|
||||
const ChannelPermissionsRow = (): JSX.Element => {
|
||||
const intl = useIntl()
|
||||
const board = useAppSelector(getCurrentBoard)
|
||||
const [linkedChannel, setLinkedChannel] = useState<Channel|null>(null)
|
||||
const [showUnlinkChannelConfirmation, setShowUnlinkChannelConfirmation] = useState<boolean>(false)
|
||||
|
||||
const onUnlinkBoard = async () => {
|
||||
const newBoard = createBoard(board)
|
||||
newBoard.channelId = ''
|
||||
mutator.updateBoard(newBoard, board, 'unlinked channel')
|
||||
setShowUnlinkChannelConfirmation(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -43,8 +46,32 @@ const ChannelPermissionsRow = (): JSX.Element => {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const confirmationDialog = (
|
||||
<ConfirmationDialogBox
|
||||
dialogBox={{
|
||||
heading: intl.formatMessage({
|
||||
id: 'shareBoard.confirm-unlink.title',
|
||||
defaultMessage: 'Unlink channel from board',
|
||||
}),
|
||||
subText: intl.formatMessage({
|
||||
id: 'shareBoard.confirm-unlink.body',
|
||||
defaultMessage: 'When you unlink a channel from a board, all members of the channel (existing and new) will loose access to it unless they are given permission separately. {lineBreak} Are you sure you want to unlink it?',
|
||||
}, {
|
||||
lineBreak: <p/>
|
||||
}),
|
||||
confirmButtonText: intl.formatMessage({
|
||||
id: 'shareBoard.confirm-unlink.confirmBtnText',
|
||||
defaultMessage: 'Yes, unlink',
|
||||
}),
|
||||
onConfirm: onUnlinkBoard,
|
||||
onClose: () => setShowUnlinkChannelConfirmation(false),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='user-item'>
|
||||
<div className='user-item channel-item'>
|
||||
{showUnlinkChannelConfirmation && confirmationDialog}
|
||||
<div className='user-item__content'>
|
||||
<span className='user-item__img'>
|
||||
{linkedChannel.type === 'P' && <PrivateIcon/>}
|
||||
@ -69,7 +96,7 @@ const ChannelPermissionsRow = (): JSX.Element => {
|
||||
id='Unlink'
|
||||
icon={<DeleteIcon/>}
|
||||
name={intl.formatMessage({id: 'BoardMember.unlinkChannel', defaultMessage: 'Unlink'})}
|
||||
onClick={onUnlinkBoard}
|
||||
onClick={() => setShowUnlinkChannelConfirmation(true)}
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
|
@ -77,21 +77,27 @@ board.cardProperties = [
|
||||
],
|
||||
},
|
||||
]
|
||||
board.channelId = 'channel_1'
|
||||
|
||||
const activeView = TestBlockFactory.createBoardView(board)
|
||||
activeView.id = 'view1'
|
||||
activeView.fields.hiddenOptionIds = []
|
||||
activeView.fields.visiblePropertyIds = ['property1']
|
||||
activeView.fields.visibleOptionIds = ['value1']
|
||||
|
||||
const fakeBoard = {id: board.id}
|
||||
activeView.boardId = fakeBoard.id
|
||||
|
||||
const card1 = TestBlockFactory.createCard(board)
|
||||
card1.id = 'card1'
|
||||
card1.title = 'card-1'
|
||||
card1.boardId = fakeBoard.id
|
||||
|
||||
const card2 = TestBlockFactory.createCard(board)
|
||||
card2.id = 'card2'
|
||||
card2.title = 'card-2'
|
||||
card2.boardId = fakeBoard.id
|
||||
|
||||
const card3 = TestBlockFactory.createCard(board)
|
||||
card3.id = 'card3'
|
||||
card3.title = 'card-3'
|
||||
@ -187,6 +193,8 @@ describe('src/components/shareBoard/shareBoard', () => {
|
||||
viewId,
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
mockedOctoClient.getChannel.mockResolvedValue({type: 'P', display_name: 'Dunder Mifflin Party Planing Committee'} as Channel)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -335,6 +343,7 @@ describe('src/components/shareBoard/shareBoard', () => {
|
||||
expect(mockedOctoClient.setSharing).toBeCalledTimes(1)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('return shareBoard, and click switch', async () => {
|
||||
const sharing:ISharing = {
|
||||
id: boardId,
|
||||
@ -374,6 +383,7 @@ describe('src/components/shareBoard/shareBoard', () => {
|
||||
expect(mockedOctoClient.getSharing).toBeCalledTimes(2)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('return shareBoardComponent and click Switch without sharing', async () => {
|
||||
const sharing:ISharing = {
|
||||
id: '',
|
||||
@ -425,6 +435,7 @@ describe('src/components/shareBoard/shareBoard', () => {
|
||||
expect(mockedUtils.createGuid).toBeCalledTimes(1)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('should match snapshot with sharing and without workspaceId and subpath', async () => {
|
||||
w.baseURL = '/test-subpath/plugins/boards'
|
||||
const sharing:ISharing = {
|
||||
@ -575,4 +586,48 @@ describe('src/components/shareBoard/shareBoard', () => {
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('confirm unlinking linked channel', async () => {
|
||||
const sharing:ISharing = {
|
||||
id: '',
|
||||
enabled: false,
|
||||
token: '',
|
||||
}
|
||||
mockedOctoClient.getSharing.mockResolvedValue(sharing)
|
||||
mockedUtils.isFocalboardPlugin.mockReturnValue(true)
|
||||
|
||||
let container: Element | DocumentFragment | null = null
|
||||
await act(async () => {
|
||||
const result = render(
|
||||
wrapDNDIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<ShareBoard
|
||||
onClose={jest.fn()}
|
||||
enableSharedBoards={true}
|
||||
/>
|
||||
</ReduxProvider>),
|
||||
{wrapper: MemoryRouter},
|
||||
)
|
||||
container = result.container
|
||||
})
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
|
||||
const channelMenuBtn = container!.querySelector('.user-item.channel-item .MenuWrapper')
|
||||
expect(channelMenuBtn).not.toBeNull()
|
||||
userEvent.click(channelMenuBtn as Element)
|
||||
|
||||
const unlinkOption = screen.getByText('Unlink')
|
||||
expect(unlinkOption).not.toBeNull()
|
||||
userEvent.click(unlinkOption)
|
||||
|
||||
const unlinkConfirmationBtn = screen.getByText('Yes, unlink')
|
||||
expect(unlinkConfirmationBtn).not.toBeNull()
|
||||
userEvent.click(unlinkConfirmationBtn)
|
||||
|
||||
expect(mockedOctoClient.patchBoard).toBeCalled()
|
||||
|
||||
const closeButton = screen.getByRole('button', {name: 'Close dialog'})
|
||||
expect(closeButton).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
@ -348,14 +348,19 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
|
||||
className={'userSearchInput'}
|
||||
cacheOptions={true}
|
||||
loadOptions={async (inputValue: string) => {
|
||||
const users = await client.searchTeamUsers(inputValue)
|
||||
const channels = await client.searchUserChannels(match.params.teamId || '', inputValue)
|
||||
const result = []
|
||||
if (users) {
|
||||
result.push({label: intl.formatMessage({id: 'shareBoard.members-select-group', defaultMessage: 'Members'}), options: users || []})
|
||||
}
|
||||
if (channels) {
|
||||
result.push({label: intl.formatMessage({id: 'shareBoard.channels-select-group', defaultMessage: 'Channels'}), options: channels || []})
|
||||
if (Utils.isFocalboardPlugin()) {
|
||||
const users = await client.searchTeamUsers(inputValue)
|
||||
if (users) {
|
||||
result.push({label: intl.formatMessage({id: 'shareBoard.members-select-group', defaultMessage: 'Members'}), options: users || []})
|
||||
}
|
||||
const channels = await client.searchUserChannels(match.params.teamId || '', inputValue)
|
||||
if (channels) {
|
||||
result.push({label: intl.formatMessage({id: 'shareBoard.channels-select-group', defaultMessage: 'Channels'}), options: channels || []})
|
||||
}
|
||||
} else {
|
||||
const users = await client.searchTeamUsers(inputValue) || []
|
||||
result.push(...users)
|
||||
}
|
||||
return result
|
||||
}}
|
||||
|
@ -163,6 +163,19 @@
|
||||
right: calc(100% - 480px + 50px);
|
||||
left: calc(240px - 50px);
|
||||
}
|
||||
|
||||
.boardMoveToCategorySubmenu {
|
||||
.menu-options {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media only screen and (max-height: 768px) {
|
||||
.menu-options {
|
||||
max-height: min(350px, 50vh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.team-sidebar + .product-wrapper {
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import React, {useCallback, useRef, useState} from 'react'
|
||||
import {useIntl} from 'react-intl'
|
||||
import {useHistory, useRouteMatch} from "react-router-dom"
|
||||
|
||||
@ -106,12 +106,15 @@ const SidebarBoardItem = (props: Props) => {
|
||||
|
||||
}, [board.id])
|
||||
|
||||
const boardItemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const title = board.title || intl.formatMessage({id: 'Sidebar.untitled-board', defaultMessage: '(Untitled Board)'})
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`SidebarBoardItem subitem ${props.isActive ? 'active' : ''}`}
|
||||
onClick={() => props.showBoard(board.id)}
|
||||
ref={boardItemRef}
|
||||
>
|
||||
<div className='octo-sidebar-icon'>
|
||||
{board.icon || <BoardIcon/>}
|
||||
@ -136,7 +139,8 @@ const SidebarBoardItem = (props: Props) => {
|
||||
<IconButton icon={<OptionsIcon/>}/>
|
||||
<Menu
|
||||
fixed={true}
|
||||
position='left'
|
||||
position='auto'
|
||||
parentRef={boardItemRef}
|
||||
>
|
||||
<BoardPermissionGate
|
||||
boardId={board.id}
|
||||
@ -155,9 +159,10 @@ const SidebarBoardItem = (props: Props) => {
|
||||
<Menu.SubMenu
|
||||
key={`moveBlock-${board.id}`}
|
||||
id='moveBlock'
|
||||
className='boardMoveToCategorySubmenu'
|
||||
name={intl.formatMessage({id: 'SidebarCategories.BlocksMenu.Move', defaultMessage: 'Move To...'})}
|
||||
icon={<CreateNewFolder/>}
|
||||
position='bottom'
|
||||
position='auto'
|
||||
>
|
||||
{generateMoveToCategoryOptions(board.id)}
|
||||
</Menu.SubMenu>
|
||||
|
@ -154,9 +154,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.Menu.noselect.left {
|
||||
.Menu.noselect:not(.SubMenu) {
|
||||
position: fixed;
|
||||
right: calc(100% - 480px + 50px);
|
||||
left: calc(240px - 50px);
|
||||
|
||||
> .left {
|
||||
right: calc(100% - 480px - 64px + 50px);
|
||||
left: calc(64px + 240px - 50px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import React, {useCallback, useRef, useState} from 'react'
|
||||
import {FormattedMessage, useIntl} from 'react-intl'
|
||||
import {generatePath, useHistory, useRouteMatch} from 'react-router-dom'
|
||||
|
||||
@ -59,6 +59,8 @@ const SidebarCategory = (props: Props) => {
|
||||
const team = useAppSelector(getCurrentTeam)
|
||||
const teamID = team?.id || ''
|
||||
|
||||
const menuWrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const showBoard = useCallback((boardId) => {
|
||||
Utils.showBoard(boardId, match, history)
|
||||
props.hideSidebar()
|
||||
@ -71,7 +73,7 @@ const SidebarCategory = (props: Props) => {
|
||||
if (boardId !== match.params.boardId && viewId !== match.params.viewId) {
|
||||
params.cardId = undefined
|
||||
}
|
||||
const newPath = generatePath(match.path, params)
|
||||
const newPath = generatePath(Utils.getBoardPagePath(match.path), params)
|
||||
history.push(newPath)
|
||||
props.hideSidebar()
|
||||
}, [match, history])
|
||||
@ -138,7 +140,7 @@ const SidebarCategory = (props: Props) => {
|
||||
}, [showBoard, deleteBoard, props.boards])
|
||||
|
||||
return (
|
||||
<div className='SidebarCategory'>
|
||||
<div className='SidebarCategory' ref={menuWrapperRef}>
|
||||
<div
|
||||
className={`octo-sidebar-item category ' ${collapsed ? 'collapsed' : 'expanded'} ${props.categoryBoards.id === props.activeCategoryId ? 'active' : ''}`}
|
||||
>
|
||||
@ -156,7 +158,10 @@ const SidebarCategory = (props: Props) => {
|
||||
onToggle={(open) => setCategoryMenuOpen(open)}
|
||||
>
|
||||
<IconButton icon={<OptionsIcon/>}/>
|
||||
<Menu position='left'>
|
||||
<Menu
|
||||
position='auto'
|
||||
parentRef={menuWrapperRef}
|
||||
>
|
||||
<Menu.Text
|
||||
id='createNewCategory'
|
||||
name={intl.formatMessage({id: 'SidebarCategories.CategoryMenu.CreateNew', defaultMessage: 'Create New Category'})}
|
||||
|
@ -37,7 +37,7 @@ const ViewMenu = (props: Props) => {
|
||||
const match = useRouteMatch()
|
||||
|
||||
const showView = useCallback((viewId) => {
|
||||
let newPath = generatePath(match.path, {...match.params, viewId: viewId || ''})
|
||||
let newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, viewId: viewId || ''})
|
||||
if (props.readonly) {
|
||||
newPath += `?r=${Utils.getReadToken()}`
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ import React, {useCallback, useEffect, useState} from 'react'
|
||||
import {generatePath, useRouteMatch, useHistory} from 'react-router-dom'
|
||||
import {FormattedMessage} from 'react-intl'
|
||||
|
||||
import {getCurrentTeam} from '../store/teams'
|
||||
import {getCurrentBoard, isLoadingBoard, getTemplates} from '../store/boards'
|
||||
import {refreshCards, getCardLimitTimestamp, getCurrentBoardHiddenCardsCount, setLimitTimestamp, getCurrentViewCardsSortedFilteredAndGrouped, setCurrent as setCurrentCard} from '../store/cards'
|
||||
import {
|
||||
@ -33,9 +32,8 @@ type Props = {
|
||||
}
|
||||
|
||||
function CenterContent(props: Props) {
|
||||
const team = useAppSelector(getCurrentTeam)
|
||||
const isLoading = useAppSelector(isLoadingBoard)
|
||||
const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string}>()
|
||||
const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, channelId?: string}>()
|
||||
const board = useAppSelector(getCurrentBoard)
|
||||
const templates = useAppSelector(getTemplates)
|
||||
const cards = useAppSelector(getCurrentViewCardsSortedFilteredAndGrouped)
|
||||
@ -51,7 +49,7 @@ function CenterContent(props: Props) {
|
||||
|
||||
const showCard = useCallback((cardId?: string) => {
|
||||
const params = {...match.params, cardId}
|
||||
let newPath = generatePath(match.path, params)
|
||||
let newPath = generatePath(Utils.getBoardPagePath(match.path), params)
|
||||
if (props.readonly) {
|
||||
newPath += `?r=${Utils.getReadToken()}`
|
||||
}
|
||||
@ -115,20 +113,16 @@ function CenterContent(props: Props) {
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='BoardTemplateSelector.plugin.no-content-title'
|
||||
defaultMessage='Create a Board in {teamName}'
|
||||
values={{teamName: team?.title}}
|
||||
defaultMessage='Create a board'
|
||||
/>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id='BoardTemplateSelector.plugin.no-content-description'
|
||||
defaultMessage='Add a board to the sidebar using any of the templates defined below or start from scratch.{lineBreak} Members of "{teamName}" will have access to boards created here.'
|
||||
values={{
|
||||
teamName: <b>{team?.title}</b>,
|
||||
lineBreak: <br/>,
|
||||
}}
|
||||
defaultMessage='Add a board to the sidebar using any of the templates defined below or start from scratch.'
|
||||
/>
|
||||
}
|
||||
channelId={match.params.channelId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -161,6 +161,10 @@ class Constants {
|
||||
|
||||
static readonly keyCodes: {[key: string]: [string, number]} = {
|
||||
COMPOSING: ['Composing', 229],
|
||||
ESC: ['Esc', 27],
|
||||
UP: ['Up', 38],
|
||||
DOWN: ['Down', 40],
|
||||
ENTER: ['Enter', 13],
|
||||
A: ['a', 65],
|
||||
B: ['b', 66],
|
||||
C: ['c', 67],
|
||||
|
@ -58,6 +58,7 @@ import './boardPage.scss'
|
||||
|
||||
type Props = {
|
||||
readonly?: boolean
|
||||
new?: boolean
|
||||
}
|
||||
|
||||
const BoardPage = (props: Props): JSX.Element => {
|
||||
@ -202,7 +203,7 @@ const BoardPage = (props: Props): JSX.Element => {
|
||||
|
||||
return (
|
||||
<div className='BoardPage'>
|
||||
<TeamToBoardAndViewRedirect/>
|
||||
{!props.new && <TeamToBoardAndViewRedirect/>}
|
||||
<BackwardCompatibilityQueryParamsRedirect/>
|
||||
<SetWindowTitleAndIcon/>
|
||||
<UndoRedoHotKeys/>
|
||||
|
@ -7,6 +7,7 @@ import {getBoards, getCurrentBoardId} from '../../store/boards'
|
||||
import {setCurrent as setCurrentView, getCurrentBoardViews} from '../../store/views'
|
||||
import {useAppSelector, useAppDispatch} from '../../store/hooks'
|
||||
import {UserSettings} from '../../userSettings'
|
||||
import {Utils} from '../../utils'
|
||||
import {getSidebarCategories} from '../../store/sidebar'
|
||||
import {Constants} from "../../constants"
|
||||
|
||||
@ -49,7 +50,7 @@ const TeamToBoardAndViewRedirect = (): null => {
|
||||
}
|
||||
|
||||
if (boardID) {
|
||||
const newPath = generatePath(match.path, {...match.params, boardId: boardID, viewID: undefined})
|
||||
const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, boardId: boardID, viewID: undefined})
|
||||
history.replace(newPath)
|
||||
|
||||
// return from here because the loadBoardData() call
|
||||
@ -77,7 +78,7 @@ const TeamToBoardAndViewRedirect = (): null => {
|
||||
}
|
||||
|
||||
if (viewID) {
|
||||
const newPath = generatePath(match.path, {...match.params, viewId: viewID})
|
||||
const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, viewId: viewID})
|
||||
history.replace(newPath)
|
||||
}
|
||||
}
|
||||
|
@ -165,6 +165,10 @@ const FocalboardRouter = (props: Props): JSX.Element => {
|
||||
<ChangePasswordPage/>
|
||||
</FBRoute>}
|
||||
|
||||
<FBRoute path={['/team/:teamId/new/:channelId']}>
|
||||
<BoardPage new={true}/>
|
||||
</FBRoute>
|
||||
|
||||
<FBRoute path={['/team/:teamId/shared/:boardId?/:viewId?/:cardId?', '/shared/:boardId?/:viewId?/:cardId?']}>
|
||||
<BoardPage readonly={true}/>
|
||||
</FBRoute>
|
||||
|
1
webapp/src/types/index.d.ts
vendored
1
webapp/src/types/index.d.ts
vendored
@ -21,4 +21,5 @@ export type SuiteWindow = Window & {
|
||||
baseURL?: string
|
||||
frontendBaseURL?: string
|
||||
isFocalboardPlugin?: boolean
|
||||
WebappUtils?: any
|
||||
}
|
||||
|
@ -757,6 +757,13 @@ class Utils {
|
||||
return (Utils.isMac() && e.metaKey) || (!Utils.isMac() && e.ctrlKey && !e.altKey)
|
||||
}
|
||||
|
||||
static getBoardPagePath(currentPath: string) {
|
||||
if (currentPath == "/team/:teamId/new/:channelId") {
|
||||
return "/team/:teamId/:boardId?/:viewId?/:cardId?"
|
||||
}
|
||||
return currentPath
|
||||
}
|
||||
|
||||
static showBoard(
|
||||
boardId: string,
|
||||
match: routerMatch<{boardId: string, viewId?: string, cardId?: string, teamId?: string}>,
|
||||
@ -769,7 +776,7 @@ class Utils {
|
||||
params.viewId = undefined
|
||||
params.cardId = undefined
|
||||
}
|
||||
const newPath = generatePath(match.path, params)
|
||||
const newPath = generatePath(Utils.getBoardPagePath(match.path), params)
|
||||
history.push(newPath)
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import React, {CSSProperties} from 'react'
|
||||
|
||||
import SeparatorOption from './separatorOption'
|
||||
import SwitchOption from './switchOption'
|
||||
@ -11,13 +11,16 @@ import LabelOption from './labelOption'
|
||||
|
||||
import './menu.scss'
|
||||
import textInputOption from './textInputOption'
|
||||
import MenuUtil from "./menuUtil"
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
position?: 'top' | 'bottom' | 'left' | 'right'
|
||||
position?: 'top' | 'bottom' | 'left' | 'right' | 'auto'
|
||||
fixed?: boolean
|
||||
parentRef?: React.RefObject<any>
|
||||
}
|
||||
|
||||
|
||||
export default class Menu extends React.PureComponent<Props> {
|
||||
static Color = ColorOption
|
||||
static SubMenu = SubMenuOption
|
||||
@ -27,14 +30,33 @@ export default class Menu extends React.PureComponent<Props> {
|
||||
static TextInput = textInputOption
|
||||
static Label = LabelOption
|
||||
|
||||
menuRef: React.RefObject<HTMLDivElement>
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
|
||||
this.menuRef = React.createRef<HTMLDivElement>()
|
||||
}
|
||||
|
||||
public state = {
|
||||
hovering: null,
|
||||
menuStyle: {},
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
const {position, fixed, children} = this.props
|
||||
|
||||
let style: CSSProperties = {}
|
||||
if (position === 'auto' && this.props.parentRef) {
|
||||
style = MenuUtil.openUp(this.props.parentRef).style
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`Menu noselect ${position || 'bottom'} ${fixed ? ' fixed' : ''}`}>
|
||||
<div
|
||||
className={`Menu noselect ${position || 'bottom'} ${fixed ? ' fixed' : ''}`}
|
||||
style={style}
|
||||
ref={this.menuRef}
|
||||
>
|
||||
<div className='menu-contents'>
|
||||
<div className='menu-options'>
|
||||
{React.Children.map(children, (child) => (
|
||||
|
40
webapp/src/widgets/menu/menuUtil.ts
Normal file
40
webapp/src/widgets/menu/menuUtil.ts
Normal file
@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {CSSProperties} from 'react'
|
||||
|
||||
/**
|
||||
* Calculates if a menu should open aligned down or up around the `anchorRef` element.
|
||||
* This should be used to make sure the menues are always fullly visible in cases
|
||||
* when opening them close to the edges of screen.
|
||||
* @param anchorRef ref of the element with respect to which the menu position is to be calculated.
|
||||
* @param menuMargin a safe margin value to be ensured around the menu in the calculations.
|
||||
* this ensures the menu stick to the edges of the screen ans has some space around for ease of use.
|
||||
*/
|
||||
function openUp(anchorRef: React.RefObject<HTMLElement>, menuMargin = 40): {openUp: boolean , style: CSSProperties} {
|
||||
const ret = {
|
||||
openUp: false,
|
||||
style: {} as CSSProperties,
|
||||
}
|
||||
if (!anchorRef.current) {
|
||||
return ret
|
||||
}
|
||||
|
||||
const boundingRect = anchorRef.current.getBoundingClientRect()
|
||||
const y = typeof boundingRect?.y === 'undefined' ? boundingRect?.top : boundingRect.y
|
||||
const windowHeight = window.innerHeight
|
||||
const totalSpace = windowHeight - menuMargin
|
||||
const spaceOnTop = y || 0
|
||||
const spaceOnBottom = totalSpace - spaceOnTop
|
||||
ret.openUp = spaceOnTop > spaceOnBottom
|
||||
if (ret.openUp) {
|
||||
ret.style.bottom = spaceOnBottom + menuMargin
|
||||
} else {
|
||||
ret.style.top = spaceOnTop + menuMargin
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
export default {
|
||||
openUp,
|
||||
}
|
@ -1,11 +1,14 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useEffect, useState, useContext} from 'react'
|
||||
import React, {useEffect, useState, useContext, CSSProperties, useRef} from 'react'
|
||||
|
||||
import SubmenuTriangleIcon from '../icons/submenuTriangle'
|
||||
|
||||
import MenuUtil from './menuUtil'
|
||||
|
||||
import Menu from '.'
|
||||
|
||||
|
||||
import './subMenuOption.scss'
|
||||
|
||||
export const HoveringContext = React.createContext(false)
|
||||
@ -13,9 +16,10 @@ export const HoveringContext = React.createContext(false)
|
||||
type SubMenuOptionProps = {
|
||||
id: string
|
||||
name: string
|
||||
position?: 'bottom' | 'top' | 'left' | 'left-bottom'
|
||||
position?: 'bottom' | 'top' | 'left' | 'left-bottom' | 'auto'
|
||||
icon?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function SubMenuOption(props: SubMenuOptionProps): JSX.Element {
|
||||
@ -30,22 +34,44 @@ function SubMenuOption(props: SubMenuOptionProps): JSX.Element {
|
||||
}
|
||||
}, [isHovering])
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const styleRef = useRef<CSSProperties>({})
|
||||
|
||||
useEffect(() => {
|
||||
const newStyle: CSSProperties = {}
|
||||
if (props.position === 'auto' && ref.current) {
|
||||
const openUp = MenuUtil.openUp(ref)
|
||||
if (openUp.openUp) {
|
||||
newStyle.bottom = 0
|
||||
} else {
|
||||
newStyle.top = 0
|
||||
}
|
||||
}
|
||||
|
||||
styleRef.current = newStyle
|
||||
}, [ref.current])
|
||||
|
||||
return (
|
||||
<div
|
||||
id={props.id}
|
||||
className={`MenuOption SubMenuOption menu-option${openLeftClass}${isOpen ? ' menu-option-active' : ''}`}
|
||||
className={`MenuOption SubMenuOption menu-option${openLeftClass}${isOpen ? ' menu-option-active' : ''}${props.className ? ' ' + props.className : ''}`}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsOpen((open) => !open)
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
{(props.position === 'left' || props.position === 'left-bottom') && <SubmenuTriangleIcon/>}
|
||||
{props.icon ?? <div className='noicon'/>}
|
||||
<div className='menu-name'>{props.name}</div>
|
||||
{props.position !== 'left' && props.position !== 'left-bottom' && <SubmenuTriangleIcon/>}
|
||||
{isOpen &&
|
||||
<div className={'SubMenu Menu noselect ' + (props.position || 'bottom')}>
|
||||
<div
|
||||
className={'SubMenu Menu noselect ' + (props.position || 'bottom')}
|
||||
style={styleRef.current}
|
||||
>
|
||||
<div className='menu-contents'>
|
||||
<div className='menu-options'>
|
||||
{props.children}
|
||||
|
@ -4,6 +4,7 @@ import React, {useState, useRef, useEffect} from 'react'
|
||||
|
||||
type TextInputOptionProps = {
|
||||
initialValue: string,
|
||||
onConfirmValue: (value: string) => void
|
||||
onValueChanged: (value: string) => void
|
||||
}
|
||||
|
||||
@ -22,13 +23,16 @@ function TextInputOption(props: TextInputOptionProps): JSX.Element {
|
||||
type='text'
|
||||
className='PropertyMenu menu-textbox menu-option'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
props.onValueChanged(value)
|
||||
}}
|
||||
value={value}
|
||||
title={value}
|
||||
onBlur={() => props.onValueChanged(value)}
|
||||
onBlur={() => props.onConfirmValue(value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Escape') {
|
||||
props.onValueChanged(value)
|
||||
props.onConfirmValue(value)
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') {
|
||||
e.target.dispatchEvent(new Event('menuItemClicked'))
|
||||
|
@ -83,6 +83,27 @@ describe('widgets/PropertyMenu', () => {
|
||||
setTimeout(() => expect(callback).toHaveBeenCalledWith('select', 'test-property'), 2000)
|
||||
})
|
||||
|
||||
test('handles name and type change event', () => {
|
||||
const callback = jest.fn()
|
||||
const component = wrapIntl(
|
||||
<PropertyMenu
|
||||
propertyId={'id'}
|
||||
propertyName={'test-property'}
|
||||
propertyType={'text'}
|
||||
onTypeAndNameChanged={callback}
|
||||
onDelete={callback}
|
||||
/>,
|
||||
)
|
||||
const {getByDisplayValue, getByText} = render(component)
|
||||
const input = getByDisplayValue(/test-property/i)
|
||||
fireEvent.change(input, {target: {value: 'changed name'}})
|
||||
|
||||
const menuOpen = getByText(/Type: Text/i)
|
||||
fireEvent.click(menuOpen)
|
||||
fireEvent.click(getByText('Select'))
|
||||
setTimeout(() => expect(callback).toHaveBeenCalledWith('select', 'changed name'), 2000)
|
||||
})
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const callback = jest.fn()
|
||||
const component = wrapIntl(
|
||||
|
@ -91,6 +91,7 @@ export const PropertyTypes = (props: TypesProps): JSX.Element => {
|
||||
|
||||
const PropertyMenu = (props: Props) => {
|
||||
const intl = useIntl()
|
||||
let currentPropertyName = props.propertyName
|
||||
|
||||
const deleteText = intl.formatMessage({
|
||||
id: 'PropertyMenu.Delete',
|
||||
@ -101,7 +102,11 @@ const PropertyMenu = (props: Props) => {
|
||||
<Menu>
|
||||
<Menu.TextInput
|
||||
initialValue={props.propertyName}
|
||||
onValueChanged={(n) => props.onTypeAndNameChanged(props.propertyType, n)}
|
||||
onConfirmValue={(n) => {
|
||||
props.onTypeAndNameChanged(props.propertyType, n)
|
||||
currentPropertyName = n
|
||||
}}
|
||||
onValueChanged={(n) => currentPropertyName = n}
|
||||
/>
|
||||
<Menu.SubMenu
|
||||
id='type'
|
||||
@ -109,7 +114,7 @@ const PropertyMenu = (props: Props) => {
|
||||
>
|
||||
<PropertyTypes
|
||||
label={intl.formatMessage({id: 'PropertyMenu.changeType', defaultMessage: 'Change property type'})}
|
||||
onTypeSelected={(type: PropertyType) => props.onTypeAndNameChanged(type, props.propertyName)}
|
||||
onTypeSelected={(type: PropertyType) => props.onTypeAndNameChanged(type, currentPropertyName)}
|
||||
/>
|
||||
</Menu.SubMenu>
|
||||
<Menu.Text
|
||||
|
@ -25,6 +25,7 @@ Personal server settings are stored in `config.json` and are read when the serve
|
||||
| localOnly | Only allow connections from localhost | `false`
|
||||
| enableLocalMode | Enable admin APIs on local Unix port | `true`
|
||||
| localModeSocketLocation | Location of local Unix port | `/var/tmp/focalboard_local.socket`
|
||||
| enablePublicSharedBoards | Enable publishing boards for public access | `false`
|
||||
|
||||
## Resetting passwords
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user