1
0
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:
Mattermod 2022-07-29 19:18:27 +03:00 committed by GitHub
commit eb249c6392
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1182 additions and 275 deletions

View File

@ -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

View File

@ -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()

View File

@ -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)
}

View File

@ -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 {

View File

@ -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 (

View File

@ -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) => {

View File

@ -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) {

View File

@ -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
View 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)
})
}

View File

@ -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,

View File

@ -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
}

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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?"
}
}

View File

@ -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

View File

@ -81,6 +81,7 @@
overflow: hidden;
width: 100%;
max-width: 1000px;
height: 512px;
.buttons {
display: flex;

View File

@ -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',

View File

@ -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'

View File

@ -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)
}
}, [])

View File

@ -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)}
/>
)
}

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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()
})
})

View File

@ -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
}}

View File

@ -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 {

View File

@ -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>

View File

@ -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);
}
}
}

View File

@ -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'})}

View File

@ -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()}`
}

View File

@ -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}
/>
)
}

View File

@ -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],

View File

@ -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/>

View File

@ -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)
}
}

View File

@ -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>

View File

@ -21,4 +21,5 @@ export type SuiteWindow = Window & {
baseURL?: string
frontendBaseURL?: string
isFocalboardPlugin?: boolean
WebappUtils?: any
}

View File

@ -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)
}

View File

@ -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) => (

View 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,
}

View File

@ -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}

View File

@ -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'))

View File

@ -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(

View File

@ -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

View File

@ -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