1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-04-07 21:18:42 +02:00

Adding the new blocks based editor (#3825)

* Working in the new content block editor

* Moving blocksEditor content block into its own component

* Initial integration with quick development flow

* More WIP

* Adding drag and drop support with server side help

* Some extra work around the styles

* Adding image support

* Adding video and attachments, and fixing edit

* Putting everything behind a feature flag

* Adding support for download attachments

* Fixing compilation error

* Fixing linter errors

* Fixing javascript tests

* Fixing a typescript error

* Moving the move block to an action with undo support

* Fixing ci

* Fixing post merge errors

* Moving to more specific content-blocks api

* Apply suggestions from code review

Co-authored-by: Doug Lauder <wiggin77@warpmail.net>

* Fixing the behavior of certain blocks

* Fixing linter error

* Fixing javascript linter errors

* Adding permission testing for the new move content block api

* Adding some unit tests

* Improving a bit the tests

* Adding more unit tests to the backend

* Fixed PR suggestion

* Adding h1, h2 and h3 tests

* Adding image tests

* Adding video tests

* Adding attachment tests

* Adding quote block tests

* Adding divider tests

* Adding checkbox tests

* Adding list item block tests

* Adding text block tests

* Reorganizing a bit the code to support deveditor eagain

* Fixing dark theme on editor view

* Fixing linter errors

* Fixing tests and removing unneeded data-testid

* Adding root input tests

* Fixing some merge problems

* Fixing text/text.test.tsx test

* Adding more unit tests to the blocks editor

* Fix linter error

* Adding blocksEditor tests

* Fixing linter errors

* Adding tests for blockContent

* Update webapp/src/components/blocksEditor/blockContent.test.tsx

Fix linter warning

* Update webapp/src/components/blocksEditor/blockContent.test.tsx

Fix linter warning

* Update webapp/src/components/blocksEditor/blockContent.test.tsx

Fix linter error

* Fixing test

* Removing unneeded TODO

Co-authored-by: Doug Lauder <wiggin77@warpmail.net>
This commit is contained in:
Jesús Espino 2022-11-15 16:59:07 +01:00 committed by GitHub
parent 93698c9574
commit f915a20c64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
91 changed files with 8044 additions and 24 deletions

View File

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

View File

@ -95,6 +95,7 @@ func (a *API) RegisterRoutes(r *mux.Router) {
a.registerTemplatesRoutes(apiv2)
a.registerBoardsRoutes(apiv2)
a.registerBlocksRoutes(apiv2)
a.registerContentBlocksRoutes(apiv2)
a.registerStatisticsRoutes(apiv2)
// V3 routes

View File

@ -0,0 +1,103 @@
package api
import (
"net/http"
"github.com/gorilla/mux"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/services/audit"
)
func (a *API) registerContentBlocksRoutes(r *mux.Router) {
// Blocks APIs
r.HandleFunc("/content-blocks/{blockID}/moveto/{where}/{dstBlockID}", a.sessionRequired(a.handleMoveBlockTo)).Methods("POST")
}
func (a *API) handleMoveBlockTo(w http.ResponseWriter, r *http.Request) {
// swagger:operation POST /content-blocks/{blockID}/move/{where}/{dstBlockID} moveBlockTo
//
// Move a block after another block in the parent card
//
// ---
// produces:
// - application/json
// parameters:
// - name: blockID
// in: path
// description: Block ID
// required: true
// type: string
// - name: where
// in: path
// description: Relative location respect destination block (after or before)
// required: true
// type: string
// - name: dstBlockID
// in: path
// description: Destination Block ID
// required: true
// type: string
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// type: array
// items:
// "$ref": "#/definitions/Block"
// '404':
// description: board or block not found
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
blockID := mux.Vars(r)["blockID"]
dstBlockID := mux.Vars(r)["dstBlockID"]
where := mux.Vars(r)["where"]
userID := getUserID(r)
block, err := a.app.GetBlockByID(blockID)
if err != nil {
a.errorResponse(w, r, err)
return
}
dstBlock, err := a.app.GetBlockByID(dstBlockID)
if err != nil {
a.errorResponse(w, r, err)
return
}
if where != "after" && where != "before" {
a.errorResponse(w, r, model.NewErrBadRequest("invalid where parameter, use before or after"))
return
}
if userID == "" {
a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board"))
return
}
if !a.permissions.HasPermissionToBoard(userID, block.BoardID, model.PermissionManageBoardCards) {
a.errorResponse(w, r, model.NewErrPermission("access denied to modify board cards"))
return
}
auditRec := a.makeAuditRecord(r, "moveBlockTo", audit.Fail)
defer a.audit.LogRecord(audit.LevelModify, auditRec)
auditRec.AddMeta("blockID", blockID)
auditRec.AddMeta("dstBlockID", dstBlockID)
err = a.app.MoveContentBlock(block, dstBlock, where, userID)
if err != nil {
a.errorResponse(w, r, err)
return
}
// response
jsonStringResponse(w, http.StatusOK, "{}")
auditRec.Success()
}

View File

@ -0,0 +1,81 @@
package app
import (
"fmt"
"github.com/mattermost/focalboard/server/model"
"github.com/pkg/errors"
)
func (a *App) MoveContentBlock(block *model.Block, dstBlock *model.Block, where string, userID string) error {
if block.ParentID != dstBlock.ParentID {
message := fmt.Sprintf("not matching parent %s and %s", block.ParentID, dstBlock.ParentID)
return model.NewErrBadRequest(message)
}
card, err := a.GetBlockByID(block.ParentID)
if err != nil {
return err
}
contentOrderData, ok := card.Fields["contentOrder"]
var contentOrder []interface{}
if ok {
contentOrder = contentOrderData.([]interface{})
}
newContentOrder := []interface{}{}
foundDst := false
foundSrc := false
for _, id := range contentOrder {
stringID, ok := id.(string)
if !ok {
newContentOrder = append(newContentOrder, id)
continue
}
if dstBlock.ID == stringID {
foundDst = true
if where == "after" {
newContentOrder = append(newContentOrder, id)
newContentOrder = append(newContentOrder, block.ID)
} else {
newContentOrder = append(newContentOrder, block.ID)
newContentOrder = append(newContentOrder, id)
}
continue
}
if block.ID == stringID {
foundSrc = true
continue
}
newContentOrder = append(newContentOrder, id)
}
if !foundSrc {
message := fmt.Sprintf("source block %s not found", block.ID)
return model.NewErrBadRequest(message)
}
if !foundDst {
message := fmt.Sprintf("destination block %s not found", dstBlock.ID)
return model.NewErrBadRequest(message)
}
patch := &model.BlockPatch{
UpdatedFields: map[string]interface{}{
"contentOrder": newContentOrder,
},
}
_, err = a.PatchBlock(block.ParentID, patch, userID)
if errors.Is(err, model.ErrPatchUpdatesLimitedCards) {
return err
}
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,191 @@
package app
import (
"fmt"
"testing"
"github.com/golang/mock/gomock"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"github.com/mattermost/focalboard/server/model"
)
type contentOrderMatcher struct {
contentOrder []string
}
func NewContentOrderMatcher(contentOrder []string) contentOrderMatcher {
return contentOrderMatcher{contentOrder}
}
func (com contentOrderMatcher) Matches(x interface{}) bool {
patch, ok := x.(*model.BlockPatch)
if !ok {
return false
}
contentOrderData, ok := patch.UpdatedFields["contentOrder"]
if !ok {
return false
}
contentOrder, ok := contentOrderData.([]interface{})
if !ok {
return false
}
if len(contentOrder) != len(com.contentOrder) {
return false
}
for i := range contentOrder {
if contentOrder[i] != com.contentOrder[i] {
return false
}
}
return true
}
func (com contentOrderMatcher) String() string {
return fmt.Sprint(&model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": com.contentOrder}})
}
func TestMoveContentBlock(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
ttCases := []struct {
name string
srcBlock model.Block
dstBlock model.Block
parentBlock *model.Block
where string
userID string
mockPatch bool
mockPatchError error
errorMessage string
expectedContentOrder []string
}{
{
name: "not matching parents",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "other-test-card"},
parentBlock: nil,
where: "after",
userID: "user-id",
errorMessage: "not matching parent test-card and other-test-card",
},
{
name: "parent not found",
srcBlock: model.Block{ID: "test-1", ParentID: "invalid-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "invalid-card"},
parentBlock: &model.Block{ID: "invalid-card"},
where: "after",
userID: "user-id",
errorMessage: "{test} not found",
},
{
name: "valid parent without content order",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card"},
where: "after",
userID: "user-id",
errorMessage: "source block test-1 not found",
},
{
name: "valid parent with content order but without test-1 in it",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-2"}}},
where: "after",
userID: "user-id",
errorMessage: "source block test-1 not found",
},
{
name: "valid parent with content order but without test-2 in it",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1"}}},
where: "after",
userID: "user-id",
errorMessage: "destination block test-2 not found",
},
{
name: "valid request but fail on patchparent with content order",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2"}}},
where: "after",
userID: "user-id",
mockPatch: true,
mockPatchError: errors.New("test error"),
errorMessage: "test error",
},
{
name: "valid request with not real change",
srcBlock: model.Block{ID: "test-2", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-1", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
where: "after",
userID: "user-id",
mockPatch: true,
errorMessage: "",
expectedContentOrder: []string{"test-1", "test-2", "test-3"},
},
{
name: "valid request changing order with before",
srcBlock: model.Block{ID: "test-2", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-1", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
where: "before",
userID: "user-id",
mockPatch: true,
errorMessage: "",
expectedContentOrder: []string{"test-2", "test-1", "test-3"},
},
{
name: "valid request changing order with after",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
where: "after",
userID: "user-id",
mockPatch: true,
errorMessage: "",
expectedContentOrder: []string{"test-2", "test-1", "test-3"},
},
}
for _, tc := range ttCases {
t.Run(tc.name, func(t *testing.T) {
if tc.parentBlock != nil {
if tc.parentBlock.ID == "invalid-card" {
th.Store.EXPECT().GetBlock(tc.srcBlock.ParentID).Return(nil, model.NewErrNotFound("test"))
} else {
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
if tc.mockPatch {
if tc.mockPatchError != nil {
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(nil, tc.mockPatchError)
} else {
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
th.Store.EXPECT().PatchBlock(tc.parentBlock.ID, NewContentOrderMatcher(tc.expectedContentOrder), gomock.Eq("user-id")).Return(nil)
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
th.Store.EXPECT().GetBoard(tc.parentBlock.BoardID).Return(&model.Board{ID: "test-board"}, nil)
// this call comes from the WS server notification
th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1)
}
}
}
}
err := th.App.MoveContentBlock(&tc.srcBlock, &tc.dstBlock, tc.where, tc.userID)
if tc.errorMessage == "" {
require.NoError(t, err)
} else {
require.EqualError(t, err, tc.errorMessage)
}
})
}
}

View File

@ -903,6 +903,16 @@ func (c *Client) GetLimits() (*model.BoardsCloudLimits, *Response) {
return limits, BuildResponse(r)
}
func (c *Client) MoveContentBlock(srcBlockID string, dstBlockID string, where string, userID string) (bool, *Response) {
r, err := c.DoAPIPost("/content-blocks/"+srcBlockID+"/moveto/"+where+"/"+dstBlockID, "")
if err != nil {
return false, BuildErrorResponse(r, err)
}
defer closeBody(r)
return true, BuildResponse(r)
}
func (c *Client) GetStatistics() (*model.BoardsStatistics, *Response) {
r, err := c.DoAPIGet("/statistics", "")
if err != nil {

View File

@ -0,0 +1,182 @@
package integrationtests
import (
"fmt"
"testing"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/utils"
"github.com/stretchr/testify/require"
)
func TestMoveContentBlock(t *testing.T) {
th := SetupTestHelperWithToken(t).Start()
defer th.TearDown()
board := th.CreateBoard("team-id", model.BoardTypeOpen)
cardID1 := utils.NewID(utils.IDTypeBlock)
cardID2 := utils.NewID(utils.IDTypeBlock)
contentBlockID1 := utils.NewID(utils.IDTypeBlock)
contentBlockID2 := utils.NewID(utils.IDTypeBlock)
contentBlockID3 := utils.NewID(utils.IDTypeBlock)
contentBlockID4 := utils.NewID(utils.IDTypeBlock)
contentBlockID5 := utils.NewID(utils.IDTypeBlock)
contentBlockID6 := utils.NewID(utils.IDTypeBlock)
card1 := &model.Block{
ID: cardID1,
BoardID: board.ID,
CreateAt: 1,
UpdateAt: 1,
Type: model.TypeCard,
Fields: map[string]interface{}{
"contentOrder": []string{contentBlockID1, contentBlockID2, contentBlockID3},
},
}
card2 := &model.Block{
ID: cardID2,
BoardID: board.ID,
CreateAt: 1,
UpdateAt: 1,
Type: model.TypeCard,
Fields: map[string]interface{}{
"contentOrder": []string{contentBlockID4, contentBlockID5, contentBlockID6},
},
}
contentBlock1 := &model.Block{
ID: contentBlockID1,
BoardID: board.ID,
CreateAt: 1,
UpdateAt: 1,
Type: model.TypeCard,
ParentID: cardID1,
}
contentBlock2 := &model.Block{
ID: contentBlockID2,
BoardID: board.ID,
CreateAt: 1,
UpdateAt: 1,
Type: model.TypeCard,
ParentID: cardID1,
}
contentBlock3 := &model.Block{
ID: contentBlockID3,
BoardID: board.ID,
CreateAt: 1,
UpdateAt: 1,
Type: model.TypeCard,
ParentID: cardID1,
}
contentBlock4 := &model.Block{
ID: contentBlockID4,
BoardID: board.ID,
CreateAt: 1,
UpdateAt: 1,
Type: model.TypeCard,
ParentID: cardID2,
}
contentBlock5 := &model.Block{
ID: contentBlockID5,
BoardID: board.ID,
CreateAt: 1,
UpdateAt: 1,
Type: model.TypeCard,
ParentID: cardID2,
}
contentBlock6 := &model.Block{
ID: contentBlockID6,
BoardID: board.ID,
CreateAt: 1,
UpdateAt: 1,
Type: model.TypeCard,
ParentID: cardID2,
}
newBlocks := []*model.Block{
contentBlock1,
contentBlock2,
contentBlock3,
contentBlock4,
contentBlock5,
contentBlock6,
card1,
card2,
}
createdBlocks, resp := th.Client.InsertBlocks(board.ID, newBlocks, false)
require.NoError(t, resp.Error)
require.Len(t, newBlocks, 8)
contentBlock1.ID = createdBlocks[0].ID
contentBlock2.ID = createdBlocks[1].ID
contentBlock3.ID = createdBlocks[2].ID
contentBlock4.ID = createdBlocks[3].ID
contentBlock5.ID = createdBlocks[4].ID
contentBlock6.ID = createdBlocks[5].ID
card1.ID = createdBlocks[6].ID
card2.ID = createdBlocks[7].ID
ttCases := []struct {
name string
srcBlockID string
dstBlockID string
where string
userID string
errorMessage string
expectedContentOrder []interface{}
}{
{
name: "not matching parents",
srcBlockID: contentBlock1.ID,
dstBlockID: contentBlock4.ID,
where: "after",
userID: "user-id",
errorMessage: fmt.Sprintf("payload: {\"error\":\"not matching parent %s and %s\",\"errorCode\":400}", card1.ID, card2.ID),
expectedContentOrder: []interface{}{contentBlock1.ID, contentBlock2.ID, contentBlock3.ID},
},
{
name: "valid request with not real change",
srcBlockID: contentBlock2.ID,
dstBlockID: contentBlock1.ID,
where: "after",
userID: "user-id",
errorMessage: "",
expectedContentOrder: []interface{}{contentBlock1.ID, contentBlock2.ID, contentBlock3.ID},
},
{
name: "valid request changing order with before",
srcBlockID: contentBlock2.ID,
dstBlockID: contentBlock1.ID,
where: "before",
userID: "user-id",
errorMessage: "",
expectedContentOrder: []interface{}{contentBlock2.ID, contentBlock1.ID, contentBlock3.ID},
},
{
name: "valid request changing order with after",
srcBlockID: contentBlock1.ID,
dstBlockID: contentBlock2.ID,
where: "after",
userID: "user-id",
errorMessage: "",
expectedContentOrder: []interface{}{contentBlock2.ID, contentBlock1.ID, contentBlock3.ID},
},
}
for _, tc := range ttCases {
t.Run(tc.name, func(t *testing.T) {
_, resp := th.Client.MoveContentBlock(tc.srcBlockID, tc.dstBlockID, tc.where, tc.userID)
if tc.errorMessage == "" {
require.NoError(t, resp.Error)
} else {
require.EqualError(t, resp.Error, tc.errorMessage)
}
parent, err := th.Server.App().GetBlockByID(card1.ID)
require.NoError(t, err)
require.Equal(t, parent.Fields["contentOrder"], tc.expectedContentOrder)
})
}
}

View File

@ -151,19 +151,19 @@ func setupData(t *testing.T, th *TestHelper) TestData {
true,
)
require.NoError(t, err)
err = th.Server.App().InsertBlock(&model.Block{ID: "block-1", Title: "Test", Type: "card", BoardID: customTemplate1.ID}, userAdminID)
err = th.Server.App().InsertBlock(&model.Block{ID: "block-1", Title: "Test", Type: "card", BoardID: customTemplate1.ID, Fields: map[string]interface{}{}}, userAdminID)
require.NoError(t, err)
customTemplate2, err := th.Server.App().CreateBoard(
&model.Board{Title: "Custom template 2", TeamID: "test-team", IsTemplate: true, Type: model.BoardTypePrivate, MinimumRole: "viewer"},
userAdminID,
true)
require.NoError(t, err)
err = th.Server.App().InsertBlock(&model.Block{ID: "block-2", Title: "Test", Type: "card", BoardID: customTemplate2.ID}, userAdminID)
err = th.Server.App().InsertBlock(&model.Block{ID: "block-2", Title: "Test", Type: "card", BoardID: customTemplate2.ID, Fields: map[string]interface{}{}}, userAdminID)
require.NoError(t, err)
board1, err := th.Server.App().CreateBoard(&model.Board{Title: "Board 1", TeamID: "test-team", Type: model.BoardTypeOpen, MinimumRole: "viewer"}, userAdminID, true)
require.NoError(t, err)
err = th.Server.App().InsertBlock(&model.Block{ID: "block-3", Title: "Test", Type: "card", BoardID: board1.ID}, userAdminID)
err = th.Server.App().InsertBlock(&model.Block{ID: "block-3", Title: "Test", Type: "card", BoardID: board1.ID, Fields: map[string]interface{}{}}, userAdminID)
require.NoError(t, err)
board2, err := th.Server.App().CreateBoard(&model.Board{Title: "Board 2", TeamID: "test-team", Type: model.BoardTypePrivate, MinimumRole: "viewer"}, userAdminID, true)
require.NoError(t, err)
@ -179,7 +179,7 @@ func setupData(t *testing.T, th *TestHelper) TestData {
require.Equal(t, boardMember.UserID, userAdminID)
require.Equal(t, boardMember.BoardID, board2.ID)
err = th.Server.App().InsertBlock(&model.Block{ID: "block-4", Title: "Test", Type: "card", BoardID: board2.ID}, userAdminID)
err = th.Server.App().InsertBlock(&model.Block{ID: "block-4", Title: "Test", Type: "card", BoardID: board2.ID, Fields: map[string]interface{}{}}, userAdminID)
require.NoError(t, err)
err = th.Server.App().UpsertSharing(model.Sharing{ID: board2.ID, Enabled: true, Token: "valid", ModifiedBy: userAdminID, UpdateAt: model.GetMillis()})
@ -1489,6 +1489,93 @@ func TestPermissionsUndeleteBoardBlock(t *testing.T) {
})
}
func TestPermissionsMoveContentBlock(t *testing.T) {
extraSetup := func(t *testing.T, th *TestHelper, testData TestData) {
err := th.Server.App().InsertBlock(&model.Block{ID: "content-1-1", Title: "Test", Type: "text", BoardID: testData.publicTemplate.ID, ParentID: "block-1"}, userAdmin)
require.NoError(t, err)
err = th.Server.App().InsertBlock(&model.Block{ID: "content-1-2", Title: "Test", Type: "text", BoardID: testData.publicTemplate.ID, ParentID: "block-1"}, userAdmin)
require.NoError(t, err)
_, err = th.Server.App().PatchBlock("block-1", &model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": []string{"content-1-1", "content-1-2"}}}, userAdmin)
require.NoError(t, err)
err = th.Server.App().InsertBlock(&model.Block{ID: "content-2-1", Title: "Test", Type: "text", BoardID: testData.privateTemplate.ID, ParentID: "block-2"}, userAdmin)
require.NoError(t, err)
err = th.Server.App().InsertBlock(&model.Block{ID: "content-2-2", Title: "Test", Type: "text", BoardID: testData.privateTemplate.ID, ParentID: "block-2"}, userAdmin)
require.NoError(t, err)
_, err = th.Server.App().PatchBlock("block-2", &model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": []string{"content-2-1", "content-2-2"}}}, userAdmin)
require.NoError(t, err)
err = th.Server.App().InsertBlock(&model.Block{ID: "content-3-1", Title: "Test", Type: "text", BoardID: testData.publicBoard.ID, ParentID: "block-3"}, userAdmin)
require.NoError(t, err)
err = th.Server.App().InsertBlock(&model.Block{ID: "content-3-2", Title: "Test", Type: "text", BoardID: testData.publicBoard.ID, ParentID: "block-3"}, userAdmin)
require.NoError(t, err)
_, err = th.Server.App().PatchBlock("block-3", &model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": []string{"content-3-1", "content-3-2"}}}, userAdmin)
require.NoError(t, err)
err = th.Server.App().InsertBlock(&model.Block{ID: "content-4-1", Title: "Test", Type: "text", BoardID: testData.privateBoard.ID, ParentID: "block-4"}, userAdmin)
require.NoError(t, err)
err = th.Server.App().InsertBlock(&model.Block{ID: "content-4-2", Title: "Test", Type: "text", BoardID: testData.privateBoard.ID, ParentID: "block-4"}, userAdmin)
require.NoError(t, err)
_, err = th.Server.App().PatchBlock("block-4", &model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": []string{"content-4-1", "content-4-2"}}}, userAdmin)
require.NoError(t, err)
}
ttCases := []TestCase{
{"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userAnon, http.StatusUnauthorized, 0},
{"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userNoTeamMember, http.StatusForbidden, 0},
{"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userTeamMember, http.StatusForbidden, 0},
{"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userViewer, http.StatusForbidden, 0},
{"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userCommenter, http.StatusForbidden, 0},
{"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userEditor, http.StatusOK, 0},
{"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userAdmin, http.StatusOK, 0},
{"/content-blocks/content-4-1/moveto/after/content-4-2", methodPost, "{}", userGuest, http.StatusForbidden, 0},
{"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userAnon, http.StatusUnauthorized, 0},
{"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userNoTeamMember, http.StatusForbidden, 0},
{"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userTeamMember, http.StatusForbidden, 0},
{"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userViewer, http.StatusForbidden, 0},
{"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userCommenter, http.StatusForbidden, 0},
{"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userEditor, http.StatusOK, 0},
{"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userAdmin, http.StatusOK, 0},
{"/content-blocks/content-3-1/moveto/after/content-3-2", methodPost, "{}", userGuest, http.StatusForbidden, 0},
{"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userAnon, http.StatusUnauthorized, 0},
{"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userNoTeamMember, http.StatusForbidden, 0},
{"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userTeamMember, http.StatusForbidden, 0},
{"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userViewer, http.StatusForbidden, 0},
{"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userCommenter, http.StatusForbidden, 0},
{"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userEditor, http.StatusOK, 0},
{"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userAdmin, http.StatusOK, 0},
{"/content-blocks/content-2-1/moveto/after/content-2-2", methodPost, "{}", userGuest, http.StatusForbidden, 0},
{"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userAnon, http.StatusUnauthorized, 0},
{"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userNoTeamMember, http.StatusForbidden, 0},
{"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userTeamMember, http.StatusForbidden, 0},
{"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userViewer, http.StatusForbidden, 0},
{"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userCommenter, http.StatusForbidden, 0},
{"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userEditor, http.StatusOK, 0},
{"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userAdmin, http.StatusOK, 0},
{"/content-blocks/content-1-1/moveto/after/content-1-2", methodPost, "{}", userGuest, http.StatusForbidden, 0},
// Invalid srcBlockID/dstBlockID combination
{"/content-blocks/content-1-1/moveto/after/content-2-1", methodPost, "{}", userAdmin, http.StatusBadRequest, 0},
}
t.Run("plugin", func(t *testing.T) {
th := SetupTestHelperPluginMode(t)
defer th.TearDown()
clients := setupClients(th)
testData := setupData(t, th)
extraSetup(t, th, testData)
runTestCases(t, ttCases, testData, clients)
})
t.Run("local", func(t *testing.T) {
th := SetupTestHelperLocalMode(t)
defer th.TearDown()
clients := setupLocalClients(th)
testData := setupData(t, th)
extraSetup(t, th, testData)
runTestCases(t, ttCases, testData, clients)
})
}
func TestPermissionsUndeleteBoard(t *testing.T) {
extraSetup := func(t *testing.T, th *TestHelper, testData TestData) {
err := th.Server.App().DeleteBoard(testData.publicBoard.ID, userAdmin)

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0'>
<title><%= htmlWebpackPlugin.options.title %></title>
<link rel="icon" href="/static/favicon.svg?v=1" />
</head>
<body class="focalboard-body">
<div id="focalboard-app">
</div>
<div id="focalboard-root-portal">
</div>
</body>
</html>

2473
webapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@
"pack": "cross-env NODE_ENV=production webpack --config webpack.prod.js",
"packdev": "cross-env NODE_ENV=dev webpack --config webpack.dev.js",
"watchdev": "cross-env NODE_ENV=dev webpack --watch --progress --config webpack.dev.js",
"deveditor": "cross-env NODE_ENV=dev webpack server --config webpack.editor.js",
"test": "jest",
"updatesnapshot": "jest --updateSnapshot",
"check": "eslint --ext .tsx,.ts . --quiet --cache && stylelint **/*.scss",
@ -150,6 +151,7 @@
"typescript": "^4.6.3",
"webpack": "^5.70.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.11.1",
"webpack-merge": "^5.8.0"
},
"optionalDependencies": {

View File

@ -5,7 +5,7 @@ import difference from 'lodash/difference'
import {Utils} from '../utils'
const contentBlockTypes = ['text', 'image', 'divider', 'checkbox'] as const
const contentBlockTypes = ['text', 'image', 'divider', 'checkbox', 'h1', 'h2', 'h3', 'list-item', 'attachment', 'quote', 'video'] as const
// ToDo: remove type board
const blockTypes = [...contentBlockTypes, 'board', 'view', 'card', 'comment', 'unknown'] as const

View File

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ContentBlock} from './contentBlock'
import {Block, createBlock} from './block'
type H1Block = ContentBlock & {
type: 'h1'
}
function createH1Block(block?: Block): H1Block {
return {
...createBlock(block),
type: 'h1',
}
}
export {H1Block, createH1Block}

View File

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ContentBlock} from './contentBlock'
import {Block, createBlock} from './block'
type H2Block = ContentBlock & {
type: 'h2'
}
function createH2Block(block?: Block): H2Block {
return {
...createBlock(block),
type: 'h2',
}
}
export {H2Block, createH2Block}

View File

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ContentBlock} from './contentBlock'
import {Block, createBlock} from './block'
type H3Block = ContentBlock & {
type: 'h3'
}
function createH3Block(block?: Block): H3Block {
return {
...createBlock(block),
type: 'h3',
}
}
export {H3Block, createH3Block}

View File

@ -0,0 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blockContent should match snapshot 1`] = `
<div>
<div
class="BlockContent "
data-testid="block-content"
style="opacity: 1;"
>
<span
class="action"
data-testid="add-action"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</span>
<span
class="action"
draggable="true"
>
<svg
class="GripIcon Icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0V0z"
fill="none"
/>
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
<div
class="content"
>
<div>
<h1
id="title"
>
Title
</h1>
</div>
</div>
</div>
</div>
`;
exports[`components/blocksEditor/blockContent should match snapshot editing 1`] = `
<div>
<div
class="Editor"
>
<input
class="H1"
data-testid="h1"
value="Title"
/>
</div>
</div>
`;

View File

@ -0,0 +1,447 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocksEditor should match snapshot on empty 1`] = `
<div>
<div
class="BlocksEditor"
>
<div
class="Editor"
>
<div
class="RootInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class=" css-4bb158-control"
>
<div
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-14el2xx-placeholder"
id="react-select-2-placeholder"
>
Introduce your text or your slash command
</div>
<div
class=" css-g5309v-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-describedby="react-select-2-placeholder"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
id="react-select-2-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
class=" css-1hb7zxy-IndicatorsContainer"
/>
</div>
</div>
</div>
</div>
</div>
`;
exports[`components/blocksEditor/blocksEditor should match snapshot with blocks 1`] = `
<div>
<div
class="BlocksEditor"
>
<div>
<div
class="BlockContent "
data-testid="block-content"
style="opacity: 1;"
>
<span
class="action"
data-testid="add-action"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</span>
<span
class="action"
draggable="true"
>
<svg
class="GripIcon Icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0V0z"
fill="none"
/>
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
<div
class="content"
>
<div>
<h1
id="title"
>
Title
</h1>
</div>
</div>
</div>
</div>
<div>
<div
class="BlockContent "
data-testid="block-content"
style="opacity: 1;"
>
<span
class="action"
data-testid="add-action"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</span>
<span
class="action"
draggable="true"
>
<svg
class="GripIcon Icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0V0z"
fill="none"
/>
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
<div
class="content"
>
<div>
<h2
id="sub-title"
>
Sub title
</h2>
</div>
</div>
</div>
</div>
<div>
<div
class="BlockContent "
data-testid="block-content"
style="opacity: 1;"
>
<span
class="action"
data-testid="add-action"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</span>
<span
class="action"
draggable="true"
>
<svg
class="GripIcon Icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0V0z"
fill="none"
/>
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
<div
class="content"
>
<div>
<h3
id="sub-sub-title"
>
Sub sub title
</h3>
</div>
</div>
</div>
</div>
<div>
<div
class="BlockContent "
data-testid="block-content"
style="opacity: 1;"
>
<span
class="action"
data-testid="add-action"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</span>
<span
class="action"
draggable="true"
>
<svg
class="GripIcon Icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0V0z"
fill="none"
/>
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
<div
class="content"
>
<div
class="octo-editor-preview"
>
<p>
Some
<strong>
markdown
</strong>
text
</p>
</div>
</div>
</div>
</div>
<div>
<div
class="BlockContent "
data-testid="block-content"
style="opacity: 1;"
>
<span
class="action"
data-testid="add-action"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</span>
<span
class="action"
draggable="true"
>
<svg
class="GripIcon Icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0V0z"
fill="none"
/>
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
<div
class="content"
>
<div
class="octo-editor-preview"
>
<p>
Some multiline
<br />
<strong>
markdown
</strong>
text
</p>
<h3
id="with-items"
>
With Items
</h3>
<ul>
<li>
Item 1
</li>
<li>
Item2
</li>
<li>
Item3
</li>
</ul>
</div>
</div>
</div>
</div>
<div>
<div
class="BlockContent "
data-testid="block-content"
style="opacity: 1;"
>
<span
class="action"
data-testid="add-action"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
</span>
<span
class="action"
draggable="true"
>
<svg
class="GripIcon Icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0V0z"
fill="none"
/>
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
<div
class="content"
>
<div
class="CheckboxView"
>
<input
checked=""
data-testid="checkbox-check"
type="checkbox"
/>
<div>
<p>
Checkbox
</p>
</div>
</div>
</div>
</div>
</div>
<div
class="Editor"
>
<div
class="RootInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-3-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class=" css-4bb158-control"
>
<div
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-14el2xx-placeholder"
id="react-select-3-placeholder"
>
Introduce your text or your slash command
</div>
<div
class=" css-g5309v-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-describedby="react-select-3-placeholder"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
id="react-select-3-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
class=" css-1hb7zxy-IndicatorsContainer"
/>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,128 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/editor should match snapshot 1`] = `
<div>
<div
class="Editor"
>
<div
class="TextContent"
data-testid="text"
>
<div
class="MarkdownEditor octo-editor active"
>
<div
class="MarkdownEditorInput"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
test-value
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`components/blocksEditor/editor should match snapshot on empty 1`] = `
<div>
<div
class="Editor"
>
<div
class="RootInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class=" css-4bb158-control"
>
<div
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-14el2xx-placeholder"
id="react-select-2-placeholder"
>
Introduce your text or your slash command
</div>
<div
class=" css-g5309v-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-describedby="react-select-2-placeholder"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
id="react-select-2-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
class=" css-1hb7zxy-IndicatorsContainer"
/>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,266 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/rootInput should match Display snapshot 1`] = `
<div>
<div
class="RootInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class=" css-4bb158-control"
>
<div
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-nkozic-Input"
data-value="test-value"
>
<input
aria-autocomplete="list"
aria-describedby="react-select-2-placeholder"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
id="react-select-2-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value="test-value"
/>
</div>
</div>
<div
class=" css-1hb7zxy-IndicatorsContainer"
/>
</div>
</div>
</div>
`;
exports[`components/blocksEditor/rootInput should match Input snapshot 1`] = `
<div>
<div
class="RootInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-3-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class=" css-4bb158-control"
>
<div
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-nkozic-Input"
data-value="test-value"
>
<input
aria-autocomplete="list"
aria-describedby="react-select-3-placeholder"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
id="react-select-3-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value="test-value"
/>
</div>
</div>
<div
class=" css-1hb7zxy-IndicatorsContainer"
/>
</div>
</div>
</div>
`;
exports[`components/blocksEditor/rootInput should match Input snapshot with menu open 1`] = `
<div>
<div
class="RootInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-4-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class=" css-4bb158-control"
>
<div
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-14el2xx-placeholder"
id="react-select-4-placeholder"
>
Introduce your text or your slash command
</div>
<div
class=" css-g5309v-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-controls="react-select-4-listbox"
aria-describedby="react-select-4-placeholder"
aria-expanded="true"
aria-haspopup="true"
aria-owns="react-select-4-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
id="react-select-4-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
class=" css-1hb7zxy-IndicatorsContainer"
/>
</div>
<div
class=" css-1aj7brc"
>
<div
class=" css-1rsmi4x-menu"
id="react-select-4-listbox"
>
<div
class=" css-g29tl0-MenuList"
>
<div
aria-disabled="false"
class=" css-erqggd-option"
id="react-select-4-option-0"
tabindex="-1"
>
/title Creates a new Title block.
</div>
<div
aria-disabled="false"
class=" css-14xsrqy-option"
id="react-select-4-option-1"
tabindex="-1"
>
/subtitle Creates a new Sub title block.
</div>
<div
aria-disabled="false"
class=" css-14xsrqy-option"
id="react-select-4-option-2"
tabindex="-1"
>
/subsubtitle Creates a new Sub Sub title block.
</div>
<div
aria-disabled="false"
class=" css-14xsrqy-option"
id="react-select-4-option-3"
tabindex="-1"
>
/image Creates a new Image block.
</div>
<div
aria-disabled="false"
class=" css-14xsrqy-option"
id="react-select-4-option-4"
tabindex="-1"
>
/text Creates a new Text block.
</div>
<div
aria-disabled="false"
class=" css-14xsrqy-option"
id="react-select-4-option-5"
tabindex="-1"
>
/divider Creates a new Divider block.
</div>
<div
aria-disabled="false"
class=" css-14xsrqy-option"
id="react-select-4-option-6"
tabindex="-1"
>
/list-item Creates a new List item block.
</div>
<div
aria-disabled="false"
class=" css-14xsrqy-option"
id="react-select-4-option-7"
tabindex="-1"
>
/attachment Creates a new Attachment block.
</div>
<div
aria-disabled="false"
class=" css-14xsrqy-option"
id="react-select-4-option-8"
tabindex="-1"
>
/quote Creates a new Quote block.
</div>
<div
aria-disabled="false"
class=" css-14xsrqy-option"
id="react-select-4-option-9"
tabindex="-1"
>
/video Creates a new Video block.
</div>
<div
aria-disabled="false"
class=" css-14xsrqy-option"
id="react-select-4-option-10"
tabindex="-1"
>
/checkbox Creates a new Checkbox block.
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,31 @@
.BlockContent {
display: flex;
&.over-up {
border-top: 1px solid rgba(128, 192, 255, 0.4);
}
&.over-down {
border-bottom: 1px solid rgba(128, 192, 255, 0.4);
}
&:hover {
.action {
opacity: 1;
.AddIcon {
opacity: 0.5;
}
}
}
.action {
transition: opacity 0.3s;
opacity: 0;
margin: 5px;
}
.content {
flex-grow: 1;
}
}

View File

@ -0,0 +1,162 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {render, screen, fireEvent, act} from '@testing-library/react'
import {mockDOM, wrapDNDIntl, mockStateStore} from '../../testUtils'
import {TestBlockFactory} from '../../test/testBlockFactory'
import BlockContent from './blockContent'
jest.mock('draft-js/lib/generateRandomKey', () => () => '123')
describe('components/blocksEditor/blockContent', () => {
beforeEach(mockDOM)
const block = {id: '1', value: 'Title', contentType: 'h1'}
const board1 = TestBlockFactory.createBoard()
board1.id = 'board-id-1'
const state = {
users: {
boardUsers: {
1: {username: 'abc'},
2: {username: 'd'},
3: {username: 'e'},
4: {username: 'f'},
5: {username: 'g'},
},
},
boards: {
current: 'board-id-1',
boards: {
[board1.id]: board1,
},
},
clientConfig: {
value: {},
},
}
const store = mockStateStore([], state)
test('should match snapshot', async () => {
let container
await act(async () => {
const result = render(wrapDNDIntl(
<ReduxProvider store={store}>
<BlockContent
boardId='fake-board-id'
block={block}
contentOrder={[block.id]}
editing={null}
setEditing={jest.fn()}
setAfterBlock={jest.fn()}
onSave={jest.fn()}
onMove={jest.fn()}
/>
</ReduxProvider>,
))
container = result.container
})
expect(container).toMatchSnapshot()
})
test('should match snapshot editing', async () => {
let container
await act(async () => {
const result = render(wrapDNDIntl(
<ReduxProvider store={store}>
<BlockContent
boardId='fake-board-id'
block={block}
contentOrder={[block.id]}
editing={block}
setEditing={jest.fn()}
setAfterBlock={jest.fn()}
onSave={jest.fn()}
onMove={jest.fn()}
/>
</ReduxProvider>,
))
container = result.container
})
expect(container).toMatchSnapshot()
})
test('should call setEditing on click the content', async () => {
const setEditing = jest.fn()
await act(async () => {
render(wrapDNDIntl(
<ReduxProvider store={store}>
<BlockContent
boardId='fake-board-id'
block={block}
contentOrder={[block.id]}
editing={null}
setEditing={setEditing}
setAfterBlock={jest.fn()}
onSave={jest.fn()}
onMove={jest.fn()}
/>
</ReduxProvider>,
))
})
const item = screen.getByTestId('block-content')
expect(setEditing).not.toBeCalled()
fireEvent.click(item)
expect(setEditing).toBeCalledWith(block)
})
test('should call setEditing on click the content', async () => {
const setAfterBlock = jest.fn()
await act(async () => {
render(wrapDNDIntl(
<ReduxProvider store={store}>
<BlockContent
boardId='fake-board-id'
block={block}
contentOrder={[block.id]}
editing={null}
setEditing={jest.fn()}
setAfterBlock={setAfterBlock}
onSave={jest.fn()}
onMove={jest.fn()}
/>
</ReduxProvider>,
))
})
const item = screen.getByTestId('add-action')
expect(setAfterBlock).not.toBeCalled()
fireEvent.click(item)
expect(setAfterBlock).toBeCalledWith(block)
})
test('should call onSave on hit enter in the input', async () => {
const onSave = jest.fn()
await act(async () => {
render(wrapDNDIntl(
<ReduxProvider store={store}>
<BlockContent
boardId='fake-board-id'
block={block}
contentOrder={[block.id]}
editing={block}
setEditing={jest.fn()}
setAfterBlock={jest.fn()}
onSave={onSave}
onMove={jest.fn()}
/>
</ReduxProvider>,
))
})
const input = screen.getByDisplayValue('Title')
expect(onSave).not.toBeCalled()
fireEvent.change(input, {target: {value: 'test'}})
fireEvent.keyDown(input, {key: 'Enter'})
expect(onSave).toBeCalledWith(expect.objectContaining({value: 'test'}))
})
})

View File

@ -0,0 +1,124 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {useDrag, useDrop} from 'react-dnd'
import GripIcon from '../../widgets/icons/grip'
import AddIcon from '../../widgets/icons/add'
import Editor from './editor'
import * as registry from './blocks'
import {BlockData} from './blocks/types'
import './blockContent.scss'
type Props = {
boardId?: string
block: BlockData
contentOrder: string[]
editing: BlockData|null
setEditing: (block: BlockData|null) => void
setAfterBlock: (block: BlockData|null) => void
onSave: (block: BlockData) => Promise<BlockData|null>
onMove: (block: BlockData, beforeBlock: BlockData|null, afterBlock: BlockData|null) => Promise<void>
}
function BlockContent(props: Props) {
const {block, editing, setEditing, onSave, contentOrder, boardId} = props
const [{isDragging}, drag, preview] = useDrag(() => ({
type: 'block',
item: block,
collect: (monitor) => ({
isDragging: Boolean(monitor.isDragging()),
}),
}), [block, contentOrder])
const [{isOver, draggingUp}, drop] = useDrop(
() => ({
accept: 'block',
drop: (item: BlockData) => {
if (item.id !== block.id) {
if (contentOrder.indexOf(item.id || '') > contentOrder.indexOf(block.id || '')) {
props.onMove(item, block, null)
} else {
props.onMove(item, null, block)
}
}
},
collect: (monitor) => ({
isOver: Boolean(monitor.isOver()) && (monitor.getItem() as BlockData).id! !== block.id,
draggingUp: (monitor.getItem() as BlockData)?.id && contentOrder.indexOf((monitor.getItem() as BlockData).id!) > contentOrder.indexOf(block.id || ''),
}),
}),
[block, props.onMove, contentOrder],
)
if (editing && editing.id === block.id) {
return (
<Editor
onSave={async (b) => {
const updatedBlock = await onSave(b)
props.setEditing(null)
props.setAfterBlock(updatedBlock)
return updatedBlock
}}
id={block.id}
initialValue={block.value}
initialContentType={block.contentType}
/>
)
}
const contentType = registry.get(block.contentType)
if (contentType && contentType.Display) {
const DisplayContent = contentType.Display
return (
<div
ref={drop}
data-testid='block-content'
className={`BlockContent ${isOver && draggingUp ? 'over-up' : ''} ${isOver && !draggingUp ? 'over-down' : ''}`}
key={block.id}
style={{
opacity: isDragging ? 0.5 : 1,
}}
onClick={() => {
setEditing(block)
}}
>
<span
className='action'
data-testid='add-action'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
props.setAfterBlock(block)
}}
>
<AddIcon/>
</span>
<span
className='action'
ref={drag}
>
<GripIcon/>
</span>
<div
className='content'
ref={preview}
>
<DisplayContent
value={block.value}
onChange={() => null}
onCancel={() => null}
onSave={(value) => onSave({...block, value})}
currentBoardId={boardId}
/>
</div>
</div>
)
}
return null
}
export default BlockContent

View File

@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocks/attachment should match Display snapshot 1`] = `
<div>
<div
class="AttachmentView"
data-testid="attachment"
>
<a
download="test-filename"
href="test.jpg"
>
📎
test-filename
</a>
</div>
</div>
`;
exports[`components/blocksEditor/blocks/attachment should match Display snapshot with empty value 1`] = `
<div>
<div
class="AttachmentView"
data-testid="attachment"
>
<a
download=""
href="#"
>
📎
</a>
</div>
</div>
`;
exports[`components/blocksEditor/blocks/attachment should match Input snapshot 1`] = `
<div>
<input
class="Attachment"
data-testid="attachment-input"
type="file"
/>
</div>
`;
exports[`components/blocksEditor/blocks/attachment should match Input snapshot with empty input 1`] = `
<div>
<input
class="Attachment"
data-testid="attachment-input"
type="file"
/>
</div>
`;

View File

@ -0,0 +1,10 @@
.Attachment {
display: none;
}
.AttachmentView {
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
}

View File

@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import {mocked} from 'jest-mock'
import octoClient from '../../../../octoClient'
import AttachmentBlock from '.'
jest.mock('../../../../octoClient')
describe('components/blocksEditor/blocks/attachment', () => {
test('should match Display snapshot', async () => {
const mockedOcto = mocked(octoClient, true)
mockedOcto.getFileAsDataUrl.mockResolvedValue({url: 'test.jpg'})
const Component = AttachmentBlock.Display
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: 'test', filename: 'test-filename'}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
await screen.findByTestId('attachment')
expect(container).toMatchSnapshot()
})
test('should match Display snapshot with empty value', async () => {
const Component = AttachmentBlock.Display
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: '', filename: ''}}
onCancel={jest.fn()}
onSave={jest.fn()}
currentBoardId=''
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
const Component = AttachmentBlock.Input
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: 'test', filename: 'test-filename'}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot with empty input', async () => {
const Component = AttachmentBlock.Input
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: '', filename: ''}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should emit onSave on change', async () => {
const onSave = jest.fn()
const Component = AttachmentBlock.Input
render(
<Component
onChange={jest.fn()}
value={{file: 'test', filename: 'test-filename'}}
onCancel={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).not.toBeCalled()
const input = screen.getByTestId('attachment-input')
fireEvent.change(input, {target: {files: {length: 1, item: () => new File([], 'test-file', {type: 'text/plain'})}}})
expect(onSave).toBeCalledWith({file: new File([], 'test-file', {type: 'text/plain'}), filename: 'test-file'})
})
})

View File

@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useEffect, useState} from 'react'
import {BlockInputProps, ContentType} from '../types'
import octoClient from '../../../../octoClient'
import './attachment.scss'
type FileInfo = {
file: string|File
filename: string
}
const Attachment: ContentType<FileInfo> = {
name: 'attachment',
displayName: 'Attachment',
slashCommand: '/attachment',
prefix: '',
runSlashCommand: (): void => {},
editable: false,
Display: (props: BlockInputProps<FileInfo>) => {
const [fileDataUrl, setFileDataUrl] = useState<string|null>(null)
useEffect(() => {
if (!fileDataUrl) {
const loadFile = async () => {
if (props.value && props.value.file && typeof props.value.file === 'string') {
const fileURL = await octoClient.getFileAsDataUrl(props.currentBoardId || '', props.value.file)
setFileDataUrl(fileURL.url || '')
}
}
loadFile()
}
}, [props.value, props.value.file, props.currentBoardId])
return (
<div
className='AttachmentView'
data-testid='attachment'
>
<a
href={fileDataUrl || '#'}
onClick={(e) => e.stopPropagation()}
download={props.value.filename}
>
{'📎'} {props.value.filename}
</a>
</div>
)
},
Input: (props: BlockInputProps<FileInfo>) => {
const ref = useRef<HTMLInputElement|null>(null)
useEffect(() => {
ref.current?.click()
}, [])
return (
<input
ref={ref}
className='Attachment'
data-testid='attachment-input'
type='file'
onChange={(e) => {
const files = e.currentTarget?.files
if (files) {
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
if (file) {
props.onSave({file, filename: file.name})
}
}
}
}}
/>
)
},
}
Attachment.runSlashCommand = (changeType: (contentType: ContentType<FileInfo>) => void, changeValue: (value: FileInfo) => void): void => {
changeType(Attachment)
changeValue({} as any)
}
export default Attachment

View File

@ -0,0 +1,77 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocks/checkbox should match Display snapshot 1`] = `
<div>
<div
class="CheckboxView"
>
<input
checked=""
data-testid="checkbox-check"
type="checkbox"
/>
<div>
<p>
test-value
</p>
</div>
</div>
</div>
`;
exports[`components/blocksEditor/blocks/checkbox should match Display snapshot not checked 1`] = `
<div>
<div
class="CheckboxView"
>
<input
data-testid="checkbox-check"
type="checkbox"
/>
<div>
<p>
test-value
</p>
</div>
</div>
</div>
`;
exports[`components/blocksEditor/blocks/checkbox should match Input snapshot 1`] = `
<div>
<div
class="Checkbox"
>
<input
checked=""
class="inputCheck"
data-testid="checkbox-check"
type="checkbox"
/>
<input
class="inputText"
data-testid="checkbox-input"
value="test-value"
/>
</div>
</div>
`;
exports[`components/blocksEditor/blocks/checkbox should match Input snapshot not checked 1`] = `
<div>
<div
class="Checkbox"
>
<input
class="inputCheck"
data-testid="checkbox-check"
type="checkbox"
/>
<input
class="inputText"
data-testid="checkbox-input"
value="test-value"
/>
</div>
</div>
`;

View File

@ -0,0 +1,17 @@
.Checkbox {
display: flex;
.inputCheck {
width: auto;
}
.inputText {
outline: 0;
flex-grow: 1;
}
}
.CheckboxView {
display: flex;
}

View File

@ -0,0 +1,172 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import CheckboxBlock from '.'
describe('components/blocksEditor/blocks/checkbox', () => {
test('should match Display snapshot', async () => {
const Component = CheckboxBlock.Display
const {container} = render(
<Component
onChange={jest.fn()}
value={{value: 'test-value', checked: true}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Display snapshot not checked', async () => {
const Component = CheckboxBlock.Display
const {container} = render(
<Component
onChange={jest.fn()}
value={{value: 'test-value', checked: false}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
const Component = CheckboxBlock.Input
const {container} = render(
<Component
onChange={jest.fn()}
value={{value: 'test-value', checked: true}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot not checked', async () => {
const Component = CheckboxBlock.Input
const {container} = render(
<Component
onChange={jest.fn()}
value={{value: 'test-value', checked: false}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should emit onSave event on Display checkbox clicked', async () => {
const onSave = jest.fn()
const Component = CheckboxBlock.Display
render(
<Component
onChange={jest.fn()}
value={{value: 'test-value', checked: true}}
onCancel={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).not.toBeCalled()
const input = screen.getByTestId('checkbox-check')
fireEvent.click(input)
expect(onSave).toBeCalledWith({value: 'test-value', checked: false})
})
test('should emit onChange event on input change', async () => {
const onChange = jest.fn()
const Component = CheckboxBlock.Input
render(
<Component
onChange={onChange}
value={{value: 'test-value', checked: true}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(onChange).not.toBeCalled()
const input = screen.getByTestId('checkbox-input')
fireEvent.change(input, {target: {value: 'test-value-'}})
expect(onChange).toBeCalledWith({value: 'test-value-', checked: true})
})
test('should emit onChange event on checkbox click', async () => {
const onChange = jest.fn()
const Component = CheckboxBlock.Input
render(
<Component
onChange={onChange}
value={{value: 'test-value', checked: true}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(onChange).not.toBeCalled()
const input = screen.getByTestId('checkbox-check')
fireEvent.click(input)
expect(onChange).toBeCalledWith({value: 'test-value', checked: false})
})
test('should not emit onCancel event when value is not empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = CheckboxBlock.Input
render(
<Component
onChange={jest.fn()}
value={{value: 'test-value', checked: true}}
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('checkbox-input')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).not.toBeCalled()
})
test('should emit onCancel event when value is empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = CheckboxBlock.Input
render(
<Component
onChange={jest.fn()}
value={{value: '', checked: false}}
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('checkbox-input')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).toBeCalled()
})
test('should emit onSave event hit enter', async () => {
const onSave = jest.fn()
const Component = CheckboxBlock.Input
render(
<Component
onChange={jest.fn()}
value={{value: 'test-value', checked: true}}
onCancel={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).not.toBeCalled()
const input = screen.getByTestId('checkbox-input')
fireEvent.keyDown(input, {key: 'Enter'})
expect(onSave).toBeCalledWith({value: 'test-value', checked: true})
})
})

View File

@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useEffect} from 'react'
import {marked} from 'marked'
import {BlockInputProps, ContentType} from '../types'
import './checkbox.scss'
type ValueType = {
value: string
checked: boolean
}
const Checkbox: ContentType<ValueType> = {
name: 'checkbox',
displayName: 'Checkbox',
slashCommand: '/checkbox',
prefix: '[ ] ',
nextType: 'checkbox',
runSlashCommand: (): void => {},
editable: true,
Display: (props: BlockInputProps<ValueType>) => {
const renderer = new marked.Renderer()
const html = marked(props.value.value || '', {renderer, breaks: true})
return (
<div className='CheckboxView'>
<input
data-testid='checkbox-check'
type='checkbox'
onChange={(e) => {
const newValue = {checked: Boolean(e.target.checked), value: props.value.value || ''}
props.onSave(newValue)
}}
checked={props.value.checked || false}
onClick={(e) => e.stopPropagation()}
/>
<div
dangerouslySetInnerHTML={{__html: html.trim()}}
/>
</div>
)
},
Input: (props: BlockInputProps<ValueType>) => {
const ref = useRef<HTMLInputElement|null>(null)
useEffect(() => {
ref.current?.focus()
}, [])
return (
<div className='Checkbox'>
<input
type='checkbox'
data-testid='checkbox-check'
className='inputCheck'
onChange={(e) => {
let newValue = {checked: false, value: props.value.value || ''}
if (e.target.checked) {
newValue = {checked: true, value: props.value.value || ''}
}
props.onChange(newValue)
ref.current?.focus()
}}
checked={props.value.checked || false}
/>
<input
ref={ref}
data-testid='checkbox-input'
className='inputText'
onChange={(e) => {
props.onChange({checked: Boolean(props.value.checked), value: e.currentTarget.value})
}}
onKeyDown={(e) => {
if ((props.value.value || '') === '' && e.key === 'Backspace') {
props.onCancel()
}
if (e.key === 'Enter') {
props.onSave(props.value || {checked: false, value: ''})
}
}}
value={props.value.value || ''}
/>
</div>
)
},
}
Checkbox.runSlashCommand = (changeType: (contentType: ContentType<ValueType>) => void, changeValue: (value: ValueType) => void, ...args: string[]): void => {
changeType(Checkbox)
changeValue({checked: false, value: args.join(' ')})
}
export default Checkbox

View File

@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocks/divider should match Display snapshot 1`] = `
<div>
<hr
class="Divider"
/>
</div>
`;
exports[`components/blocksEditor/blocks/divider should match Input snapshot 1`] = `<div />`;

View File

@ -0,0 +1,4 @@
.Divider {
width: 100%;
margin: 5px 0;
}

View File

@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render} from '@testing-library/react'
import DividerBlock from '.'
describe('components/blocksEditor/blocks/divider', () => {
test('should match Display snapshot', async () => {
const Component = DividerBlock.Display
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
const Component = DividerBlock.Input
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should emit onSave event on mount', async () => {
const onSave = jest.fn()
const Component = DividerBlock.Input
render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).toBeCalled()
})
})

View File

@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react'
import {BlockInputProps, ContentType} from '../types'
import './divider.scss'
const Divider: ContentType = {
name: 'divider',
displayName: 'Divider',
slashCommand: '/divider',
prefix: '--- ',
runSlashCommand: (): void => {},
editable: false,
Display: () => <hr className='Divider'/>,
Input: (props: BlockInputProps) => {
useEffect(() => {
props.onSave(props.value)
}, [])
return null
},
}
Divider.runSlashCommand = (changeType: (contentType: ContentType) => void, changeValue: (value: string) => void, ...args: string[]): void => {
changeType(Divider)
changeValue(args.join(' '))
}
export default Divider

View File

@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocks/h1 should match Display snapshot 1`] = `
<div>
<div>
<h1
id="test-value"
>
test-value
</h1>
</div>
</div>
`;
exports[`components/blocksEditor/blocks/h1 should match Input snapshot 1`] = `
<div>
<input
class="H1"
data-testid="h1"
value="test-value"
/>
</div>
`;

View File

@ -0,0 +1,4 @@
.H1 {
font-size: 32px;
font-weight: 700;
}

View File

@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import H1Block from '.'
describe('components/blocksEditor/blocks/h1', () => {
test('should match Display snapshot', async () => {
const Component = H1Block.Display
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
const Component = H1Block.Input
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should emit onChange event', async () => {
const onChange = jest.fn()
const Component = H1Block.Input
render(
<Component
onChange={onChange}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(onChange).not.toBeCalled()
const input = screen.getByTestId('h1')
fireEvent.change(input, {target: {value: 'test-value-'}})
expect(onChange).toBeCalled()
})
test('should not emit onCancel event when value is not empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = H1Block.Input
render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('h1')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).not.toBeCalled()
})
test('should emit onCancel event when value is empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = H1Block.Input
render(
<Component
onChange={jest.fn()}
value=''
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('h1')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).toBeCalled()
})
test('should emit onSave event hit enter', async () => {
const onSave = jest.fn()
const Component = H1Block.Input
render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).not.toBeCalled()
const input = screen.getByTestId('h1')
fireEvent.keyDown(input, {key: 'Enter'})
expect(onSave).toBeCalled()
})
})

View File

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useEffect} from 'react'
import {marked} from 'marked'
import {BlockInputProps, ContentType} from '../types'
import './h1.scss'
const H1: ContentType = {
name: 'h1',
displayName: 'Title',
slashCommand: '/title',
prefix: '# ',
runSlashCommand: (): void => {},
editable: true,
Display: (props: BlockInputProps) => {
const renderer = new marked.Renderer()
const html = marked('# ' + props.value, {renderer, breaks: true})
return (
<div
dangerouslySetInnerHTML={{__html: html.trim()}}
/>
)
},
Input: (props: BlockInputProps) => {
const ref = useRef<HTMLInputElement|null>(null)
useEffect(() => {
ref.current?.focus()
}, [])
return (
<input
ref={ref}
className='H1'
data-testid='h1'
onChange={(e) => props.onChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (props.value === '' && e.key === 'Backspace') {
props.onCancel()
}
if (e.key === 'Enter') {
props.onSave(props.value)
}
}}
value={props.value}
/>
)
},
}
H1.runSlashCommand = (changeType: (contentType: ContentType) => void, changeValue: (value: string) => void, ...args: string[]): void => {
changeType(H1)
changeValue(args.join(' '))
}
export default H1

View File

@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocks/h2 should match Display snapshot 1`] = `
<div>
<div>
<h2
id="test-value"
>
test-value
</h2>
</div>
</div>
`;
exports[`components/blocksEditor/blocks/h2 should match Input snapshot 1`] = `
<div>
<input
class="H2"
data-testid="h2"
value="test-value"
/>
</div>
`;

View File

@ -0,0 +1,4 @@
.H2 {
font-size: 24px;
font-weight: 700;
}

View File

@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import H2Block from '.'
describe('components/blocksEditor/blocks/h2', () => {
test('should match Display snapshot', async () => {
const Component = H2Block.Display
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
const Component = H2Block.Input
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should emit onChange event', async () => {
const onChange = jest.fn()
const Component = H2Block.Input
render(
<Component
onChange={onChange}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(onChange).not.toBeCalled()
const input = screen.getByTestId('h2')
fireEvent.change(input, {target: {value: 'test-value-'}})
expect(onChange).toBeCalled()
})
test('should not emit onCancel event when value is not empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = H2Block.Input
render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('h2')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).not.toBeCalled()
})
test('should emit onCancel event when value is empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = H2Block.Input
render(
<Component
onChange={jest.fn()}
value=''
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('h2')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).toBeCalled()
})
test('should emit onSave event hit enter', async () => {
const onSave = jest.fn()
const Component = H2Block.Input
render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).not.toBeCalled()
const input = screen.getByTestId('h2')
fireEvent.keyDown(input, {key: 'Enter'})
expect(onSave).toBeCalled()
})
})

View File

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useEffect} from 'react'
import {marked} from 'marked'
import {BlockInputProps, ContentType} from '../types'
import './h2.scss'
const H2: ContentType = {
name: 'h2',
displayName: 'Sub title',
slashCommand: '/subtitle',
prefix: '## ',
runSlashCommand: (): void => {},
editable: true,
Display: (props: BlockInputProps) => {
const renderer = new marked.Renderer()
const html = marked('## ' + props.value, {renderer, breaks: true})
return (
<div
dangerouslySetInnerHTML={{__html: html.trim()}}
/>
)
},
Input: (props: BlockInputProps) => {
const ref = useRef<HTMLInputElement|null>(null)
useEffect(() => {
ref.current?.focus()
}, [])
return (
<input
ref={ref}
className='H2'
data-testid='h2'
onChange={(e) => props.onChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (props.value === '' && e.key === 'Backspace') {
props.onCancel()
}
if (e.key === 'Enter') {
props.onSave(props.value)
}
}}
value={props.value}
/>
)
},
}
H2.runSlashCommand = (changeType: (contentType: ContentType) => void, changeValue: (value: string) => void, ...args: string[]): void => {
changeType(H2)
changeValue(args.join(' '))
}
export default H2

View File

@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocks/h3 should match Display snapshot 1`] = `
<div>
<div>
<h3
id="test-value"
>
test-value
</h3>
</div>
</div>
`;
exports[`components/blocksEditor/blocks/h3 should match Input snapshot 1`] = `
<div>
<input
class="H3"
data-testid="h3"
value="test-value"
/>
</div>
`;

View File

@ -0,0 +1,4 @@
.H3 {
font-size: 18px;
font-weight: 700;
}

View File

@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import H3Block from '.'
describe('components/blocksEditor/blocks/h3', () => {
test('should match Display snapshot', async () => {
const Component = H3Block.Display
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
const Component = H3Block.Input
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should emit onChange event', async () => {
const onChange = jest.fn()
const Component = H3Block.Input
render(
<Component
onChange={onChange}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(onChange).not.toBeCalled()
const input = screen.getByTestId('h3')
fireEvent.change(input, {target: {value: 'test-value-'}})
expect(onChange).toBeCalled()
})
test('should not emit onCancel event when value is not empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = H3Block.Input
render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('h3')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).not.toBeCalled()
})
test('should emit onCancel event when value is empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = H3Block.Input
render(
<Component
onChange={jest.fn()}
value=''
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('h3')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).toBeCalled()
})
test('should emit onSave event hit enter', async () => {
const onSave = jest.fn()
const Component = H3Block.Input
render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).not.toBeCalled()
const input = screen.getByTestId('h3')
fireEvent.keyDown(input, {key: 'Enter'})
expect(onSave).toBeCalled()
})
})

View File

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useEffect} from 'react'
import {marked} from 'marked'
import {BlockInputProps, ContentType} from '../types'
import './h3.scss'
const H3: ContentType = {
name: 'h3',
displayName: 'Sub Sub title',
slashCommand: '/subsubtitle',
prefix: '### ',
runSlashCommand: (): void => {},
editable: true,
Display: (props: BlockInputProps) => {
const renderer = new marked.Renderer()
const html = marked('### ' + props.value, {renderer, breaks: true})
return (
<div
dangerouslySetInnerHTML={{__html: html.trim()}}
/>
)
},
Input: (props: BlockInputProps) => {
const ref = useRef<HTMLInputElement|null>(null)
useEffect(() => {
ref.current?.focus()
}, [])
return (
<input
ref={ref}
className='H3'
data-testid='h3'
onChange={(e) => props.onChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (props.value === '' && e.key === 'Backspace') {
props.onCancel()
}
if (e.key === 'Enter') {
props.onSave(props.value)
}
}}
value={props.value}
/>
)
},
}
H3.runSlashCommand = (changeType: (contentType: ContentType) => void, changeValue: (value: string) => void, ...args: string[]): void => {
changeType(H3)
changeValue(args.join(' '))
}
export default H3

View File

@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocks/image should match Display snapshot 1`] = `
<div>
<img
class="ImageView"
data-testid="image"
src="test.jpg"
/>
</div>
`;
exports[`components/blocksEditor/blocks/image should match Display snapshot with empty value 1`] = `<div />`;
exports[`components/blocksEditor/blocks/image should match Input snapshot 1`] = `
<div>
<div>
<img
class="ImageView"
src="test"
/>
<input
accept="image/*"
class="Image"
data-testid="image-input"
type="file"
/>
</div>
</div>
`;
exports[`components/blocksEditor/blocks/image should match Input snapshot with empty input 1`] = `
<div>
<div>
<input
accept="image/*"
class="Image"
data-testid="image-input"
type="file"
/>
</div>
</div>
`;

View File

@ -0,0 +1,7 @@
.Image {
display: none;
}
.ImageView {
max-width: 400px;
}

View File

@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import {mocked} from 'jest-mock'
import octoClient from '../../../../octoClient'
import ImageBlock from '.'
jest.mock('../../../../octoClient')
describe('components/blocksEditor/blocks/image', () => {
test('should match Display snapshot', async () => {
const mockedOcto = mocked(octoClient, true)
mockedOcto.getFileAsDataUrl.mockResolvedValue({url: 'test.jpg'})
const Component = ImageBlock.Display
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: 'test'}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
await screen.findByTestId('image')
expect(container).toMatchSnapshot()
})
test('should match Display snapshot with empty value', async () => {
const Component = ImageBlock.Display
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: ''}}
onCancel={jest.fn()}
onSave={jest.fn()}
currentBoardId=''
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
const Component = ImageBlock.Input
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: 'test'}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot with empty input', async () => {
const Component = ImageBlock.Input
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: ''}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should emit onSave on change', async () => {
const onSave = jest.fn()
const Component = ImageBlock.Input
render(
<Component
onChange={jest.fn()}
value={{file: 'test'}}
onCancel={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).not.toBeCalled()
const input = screen.getByTestId('image-input')
fireEvent.change(input, {target: {files: ['test-file']}})
expect(onSave).toBeCalledWith({file: 'test-file'})
})
})

View File

@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useEffect, useState} from 'react'
import {BlockInputProps, ContentType} from '../types'
import octoClient from '../../../../octoClient'
import './image.scss'
type FileInfo = {
file: string|File
width?: number
align?: 'left'|'center'|'right'
}
const Image: ContentType<FileInfo> = {
name: 'image',
displayName: 'Image',
slashCommand: '/image',
prefix: '',
runSlashCommand: (): void => {},
editable: false,
Display: (props: BlockInputProps<FileInfo>) => {
const [imageDataUrl, setImageDataUrl] = useState<string|null>(null)
useEffect(() => {
if (!imageDataUrl) {
const loadImage = async () => {
if (props.value && props.value.file && typeof props.value.file === 'string') {
const fileURL = await octoClient.getFileAsDataUrl(props.currentBoardId || '', props.value.file)
setImageDataUrl(fileURL.url || '')
}
}
loadImage()
}
}, [props.value, props.value.file, props.currentBoardId])
if (imageDataUrl) {
return (
<img
data-testid='image'
className='ImageView'
src={imageDataUrl}
/>
)
}
return null
},
Input: (props: BlockInputProps<FileInfo>) => {
const ref = useRef<HTMLInputElement|null>(null)
useEffect(() => {
ref.current?.click()
}, [])
return (
<div>
{props.value.file && (typeof props.value.file === 'string') && (
<img
className='ImageView'
src={props.value.file}
onClick={() => ref.current?.click()}
/>
)}
<input
ref={ref}
className='Image'
data-testid='image-input'
type='file'
accept='image/*'
onChange={(e) => {
const file = (e.currentTarget?.files || [])[0]
props.onSave({file})
}}
/>
</div>
)
},
}
Image.runSlashCommand = (changeType: (contentType: ContentType<FileInfo>) => void, changeValue: (value: FileInfo) => void): void => {
changeType(Image)
changeValue({file: ''})
}
export default Image

View File

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ContentType} from './types'
import H1 from './h1'
import H2 from './h2'
import H3 from './h3'
import Image from './image'
import Text from './text'
import Divider from './divider'
// import Markdown from './markdown'
import ListItem from './list-item'
import Attachment from './attachment'
import Quote from './quote'
import Video from './video'
import Checkbox from './checkbox'
const blocks: {[key: string]: ContentType} = {}
const blocksByPrefix: {[key: string]: ContentType} = {}
const blocksBySlashCommand: {[key: string]: ContentType} = {}
export function register(contentType: ContentType<any>) {
blocks[contentType.name] = contentType
if (contentType.prefix !== '') {
blocksByPrefix[contentType.prefix] = contentType
}
blocksBySlashCommand[contentType.slashCommand] = contentType
}
export function list() {
return Object.values(blocks)
}
export function get(name: string): ContentType {
return blocks[name]
}
export function getByPrefix(prefix: string): ContentType {
return blocksByPrefix[prefix]
}
export function isSubPrefix(text: string): boolean {
for (const ct of list()) {
if (ct.prefix !== '' && ct.prefix.startsWith(text)) {
return true
}
}
return false
}
export function getBySlashCommand(slashCommand: string): ContentType {
return blocksBySlashCommand[slashCommand]
}
export function getBySlashCommandPrefix(slashCommandPrefix: string): ContentType|null {
for (const ct of list()) {
if (ct.slashCommand.startsWith(slashCommandPrefix)) {
return ct
}
}
return null
}
register(H1)
register(H2)
register(H3)
register(Image)
register(Text)
register(Divider)
// register(Markdown)
register(ListItem)
register(Attachment)
register(Quote)
register(Video)
register(Checkbox)

View File

@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocks/list-item should match Display snapshot 1`] = `
<div>
<ul>
<li>
test-value
</li>
</ul>
</div>
`;
exports[`components/blocksEditor/blocks/list-item should match Input snapshot 1`] = `
<div>
<ul>
<li>
<input
class="ListItem"
data-testid="list-item"
value="test-value"
/>
</li>
</ul>
</div>
`;

View File

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useEffect} from 'react'
import {BlockInputProps, ContentType} from '../types'
import './list-item.scss'
const ListItem: ContentType = {
name: 'list-item',
displayName: 'List item',
slashCommand: '/list-item',
prefix: '* ',
nextType: 'list-item',
runSlashCommand: (): void => {},
editable: true,
Display: (props: BlockInputProps) => <ul><li>{props.value}</li></ul>,
Input: (props: BlockInputProps) => {
const ref = useRef<HTMLInputElement|null>(null)
useEffect(() => {
ref.current?.focus()
}, [])
return (
<ul>
<li>
<input
ref={ref}
className='ListItem'
data-testid='list-item'
onChange={(e) => props.onChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (props.value === '' && e.key === 'Backspace') {
props.onCancel()
}
if (e.key === 'Enter') {
props.onSave(props.value)
}
}}
value={props.value}
/>
</li>
</ul>
)
},
}
ListItem.runSlashCommand = (changeType: (contentType: ContentType) => void, changeValue: (value: string) => void, ...args: string[]): void => {
changeType(ListItem)
changeValue(args.join(' '))
}
export default ListItem

View File

@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import ListItemBlock from '.'
describe('components/blocksEditor/blocks/list-item', () => {
test('should match Display snapshot', async () => {
const Component = ListItemBlock.Display
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
const Component = ListItemBlock.Input
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should emit onChange event', async () => {
const onChange = jest.fn()
const Component = ListItemBlock.Input
render(
<Component
onChange={onChange}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(onChange).not.toBeCalled()
const input = screen.getByTestId('list-item')
fireEvent.change(input, {target: {value: 'test-value-'}})
expect(onChange).toBeCalled()
})
test('should not emit onCancel event when value is not empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = ListItemBlock.Input
render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('list-item')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).not.toBeCalled()
})
test('should emit onCancel event when value is empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = ListItemBlock.Input
render(
<Component
onChange={jest.fn()}
value=''
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('list-item')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).toBeCalled()
})
test('should emit onSave event hit enter', async () => {
const onSave = jest.fn()
const Component = ListItemBlock.Input
render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).not.toBeCalled()
const input = screen.getByTestId('list-item')
fireEvent.keyDown(input, {key: 'Enter'})
expect(onSave).toBeCalled()
})
})

View File

@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocks/quote should match Display snapshot 1`] = `
<div>
<div
class="Quote"
data-testid="quote"
>
<blockquote>
<p>
test-value
</p>
</blockquote>
</div>
</div>
`;
exports[`components/blocksEditor/blocks/quote should match Input snapshot 1`] = `
<div>
<blockquote
class="Quote"
>
<input
data-testid="quote"
value="test-value"
/>
</blockquote>
</div>
`;

View File

@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useEffect} from 'react'
import {marked} from 'marked'
import {BlockInputProps, ContentType} from '../types'
import './quote.scss'
const Quote: ContentType = {
name: 'quote',
displayName: 'Quote',
slashCommand: '/quote',
prefix: '> ',
Display: (props: BlockInputProps) => {
const renderer = new marked.Renderer()
const html = marked('> ' + props.value, {renderer, breaks: true})
return (
<div
className='Quote'
data-testid='quote'
dangerouslySetInnerHTML={{__html: html.trim()}}
/>
)
},
runSlashCommand: (): void => {},
editable: true,
Input: (props: BlockInputProps) => {
const ref = useRef<HTMLInputElement|null>(null)
useEffect(() => {
ref.current?.focus()
}, [])
return (
<blockquote
className='Quote'
>
<input
ref={ref}
data-testid='quote'
onChange={(e) => props.onChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (props.value === '' && e.key === 'Backspace') {
props.onCancel()
}
if (e.key === 'Enter') {
props.onSave(props.value)
}
}}
onBlur={() => props.onSave(props.value)}
value={props.value}
/>
</blockquote>
)
},
}
Quote.runSlashCommand = (changeType: (contentType: ContentType) => void, changeValue: (value: string) => void, ...args: string[]): void => {
changeType(Quote)
changeValue(args.join(' '))
}
export default Quote

View File

@ -0,0 +1,7 @@
.Editor .Quote input {
width: calc(100% - 80px);
}
.Quote {
width: 100%;
}

View File

@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import QuoteBlock from '.'
describe('components/blocksEditor/blocks/quote', () => {
test('should match Display snapshot', async () => {
const Component = QuoteBlock.Display
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
const Component = QuoteBlock.Input
const {container} = render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should emit onChange event', async () => {
const onChange = jest.fn()
const Component = QuoteBlock.Input
render(
<Component
onChange={onChange}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(onChange).not.toBeCalled()
const input = screen.getByTestId('quote')
fireEvent.change(input, {target: {value: 'test-value-'}})
expect(onChange).toBeCalled()
})
test('should not emit onCancel event when value is not empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = QuoteBlock.Input
render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('quote')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).not.toBeCalled()
})
test('should emit onCancel event when value is empty and hit backspace', async () => {
const onCancel = jest.fn()
const Component = QuoteBlock.Input
render(
<Component
onChange={jest.fn()}
value=''
onCancel={onCancel}
onSave={jest.fn()}
/>,
)
expect(onCancel).not.toBeCalled()
const input = screen.getByTestId('quote')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onCancel).toBeCalled()
})
test('should emit onSave event hit enter', async () => {
const onSave = jest.fn()
const Component = QuoteBlock.Input
render(
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).not.toBeCalled()
const input = screen.getByTestId('quote')
fireEvent.keyDown(input, {key: 'Enter'})
expect(onSave).toBeCalled()
})
})

View File

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useEffect} from 'react'
import {BlockInputProps, ContentType} from '../types'
import {Utils} from '../../../../utils'
import './text.scss'
const Text: ContentType = {
name: 'text',
displayName: 'Text',
slashCommand: '/text',
prefix: '',
runSlashCommand: (): void => {},
editable: true,
Display: (props: BlockInputProps) => {
const html: string = Utils.htmlFromMarkdown(props.value || '')
return (
<div
dangerouslySetInnerHTML={{__html: html}}
className={props.value ? 'octo-editor-preview' : 'octo-editor-preview octo-placeholder'}
/>
)
},
Input: (props: BlockInputProps) => {
const ref = useRef<HTMLInputElement|null>(null)
useEffect(() => {
ref.current?.focus()
}, [])
return (
<input
ref={ref}
className='Text'
onChange={(e) => props.onChange(e.currentTarget.value)}
onKeyDown={(e) => {
if (props.value === '' && e.key === 'Backspace') {
props.onCancel()
}
if (e.key === 'Enter') {
props.onSave(props.value)
}
}}
value={props.value}
/>
)
},
}
Text.runSlashCommand = (changeType: (contentType: ContentType) => void, changeValue: (value: string) => void, ...args: string[]): void => {
changeType(Text)
changeValue(args.join(' '))
}
export default Text

View File

@ -0,0 +1,74 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocks/text should match Display snapshot 1`] = `
<div>
<div
class="octo-editor-preview"
>
<p>
test-value
</p>
</div>
</div>
`;
exports[`components/blocksEditor/blocks/text should match Input snapshot 1`] = `
<div>
<div
class="TextContent"
data-testid="text"
>
<div
class="MarkdownEditor octo-editor active"
>
<div
class="MarkdownEditorInput"
>
<div
class="DraftEditor-root"
>
<div
class="DraftEditor-editorContainer"
>
<div
aria-autocomplete="list"
aria-expanded="false"
class="notranslate public-DraftEditor-content"
contenteditable="true"
role="combobox"
spellcheck="false"
style="outline: none; user-select: text; white-space: pre-wrap; word-wrap: break-word;"
>
<div
data-contents="true"
>
<div
class=""
data-block="true"
data-editor="123"
data-offset-key="123-0-0"
>
<div
class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"
data-offset-key="123-0-0"
>
<span
data-offset-key="123-0-0"
>
<span
data-text="true"
>
test-value
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {MarkdownEditor} from '../../../markdownEditor'
import {Utils} from '../../../../utils'
import {BlockInputProps, ContentType} from '../types'
import './text.scss'
const TextContent: ContentType = {
name: 'text',
displayName: 'Text',
slashCommand: '/text',
prefix: '',
runSlashCommand: (): void => {},
editable: true,
Display: (props: BlockInputProps) => {
const html: string = Utils.htmlFromMarkdown(props.value || '')
return (
<div
dangerouslySetInnerHTML={{__html: html}}
className={props.value ? 'octo-editor-preview' : 'octo-editor-preview octo-placeholder'}
/>
)
},
Input: (props: BlockInputProps) => {
return (
<div
className='TextContent'
data-testid='text'
>
<MarkdownEditor
autofocus={true}
onBlur={(val: string) => {
props.onSave(val)
}}
text={props.value}
saveOnEnter={true}
onEditorCancel={() => {
props.onCancel()
}}
/>
</div>
)
},
}
TextContent.runSlashCommand = (changeType: (contentType: ContentType) => void, changeValue: (value: string) => void, ...args: string[]): void => {
changeType(TextContent)
changeValue(args.join(' '))
}
export default TextContent

View File

@ -0,0 +1,6 @@
.TextContent {
width: 100%;
border: 2px solid #2684ff;
border-radius: 4px;
padding: 10px;
}

View File

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {render, act} from '@testing-library/react'
import {mockDOM, wrapDNDIntl, mockStateStore} from '../../../../testUtils'
import {TestBlockFactory} from '../../../../test/testBlockFactory'
import TextBlock from '.'
jest.mock('draft-js/lib/generateRandomKey', () => () => '123')
describe('components/blocksEditor/blocks/text', () => {
beforeEach(mockDOM)
const board1 = TestBlockFactory.createBoard()
board1.id = 'board-id-1'
const state = {
users: {
boardUsers: {
1: {username: 'abc'},
2: {username: 'd'},
3: {username: 'e'},
4: {username: 'f'},
5: {username: 'g'},
},
},
boards: {
current: 'board-id-1',
boards: {
[board1.id]: board1,
},
},
clientConfig: {
value: {},
},
}
const store = mockStateStore([], state)
test('should match Display snapshot', async () => {
const Component = TextBlock.Display
const {container} = render(wrapDNDIntl(
<ReduxProvider store={store}>
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>
</ReduxProvider>,
))
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
let container
await act(async () => {
const Component = TextBlock.Input
const result = render(wrapDNDIntl(
<ReduxProvider store={store}>
<Component
onChange={jest.fn()}
value='test-value'
onCancel={jest.fn()}
onSave={jest.fn()}
/>
</ReduxProvider>,
))
container = result.container
})
expect(container).toMatchSnapshot()
})
})

View File

@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type BlockInputProps<ValueType = string> = {
onChange: (value: ValueType) => void
value: ValueType
onCancel: () => void
onSave: (val: ValueType) => void
currentBoardId?: string
}
export type ContentType<ValueType = string> = {
name: string
displayName: string
slashCommand: string
prefix: string
editable: boolean
Input: React.FunctionComponent<BlockInputProps<ValueType>>
Display: React.FunctionComponent<BlockInputProps<ValueType>>
runSlashCommand: (changeType: (contentType: ContentType<ValueType>) => void, changeValue: (value: ValueType) => void, ...args: string[]) => void
nextType?: string
}
export type BlockData<ValueType = string> = {
id?: string
value: ValueType
contentType: string
}

View File

@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blocksEditor/blocks/video should match Display snapshot 1`] = `
<div>
<video
class="VideoView"
controls=""
data-testid="video"
height="240"
width="320"
>
<source
src="test.jpg"
/>
</video>
</div>
`;
exports[`components/blocksEditor/blocks/video should match Display snapshot with empty value 1`] = `<div />`;
exports[`components/blocksEditor/blocks/video should match Input snapshot 1`] = `
<div>
<input
accept="video/*"
class="Video"
data-testid="video-input"
type="file"
/>
</div>
`;
exports[`components/blocksEditor/blocks/video should match Input snapshot with empty input 1`] = `
<div>
<input
accept="video/*"
class="Video"
data-testid="video-input"
type="file"
/>
</div>
`;

View File

@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useEffect, useState} from 'react'
import {BlockInputProps, ContentType} from '../types'
import octoClient from '../../../../octoClient'
import './video.scss'
type FileInfo = {
file: string|File
filename: string
width?: number
align?: 'left'|'center'|'right'
}
const Video: ContentType<FileInfo> = {
name: 'video',
displayName: 'Video',
slashCommand: '/video',
prefix: '',
runSlashCommand: (): void => {},
editable: false,
Display: (props: BlockInputProps<FileInfo>) => {
const [videoDataUrl, setVideoDataUrl] = useState<string|null>(null)
useEffect(() => {
if (!videoDataUrl) {
const loadVideo = async () => {
if (props.value && props.value.file && typeof props.value.file === 'string') {
const fileURL = await octoClient.getFileAsDataUrl(props.currentBoardId || '', props.value.file)
setVideoDataUrl(fileURL.url || '')
}
}
loadVideo()
}
}, [props.value, props.value.file, props.currentBoardId])
if (videoDataUrl) {
return (
<video
width='320'
height='240'
controls={true}
className='VideoView'
data-testid='video'
>
<source src={videoDataUrl}/>
</video>
)
}
return null
},
Input: (props: BlockInputProps<FileInfo>) => {
const ref = useRef<HTMLInputElement|null>(null)
useEffect(() => {
ref.current?.click()
}, [])
return (
<input
ref={ref}
className='Video'
data-testid='video-input'
type='file'
accept='video/*'
onChange={(e) => {
const file = (e.currentTarget?.files || [])[0]
props.onSave({file, filename: file.name})
}}
/>
)
},
}
Video.runSlashCommand = (changeType: (contentType: ContentType<FileInfo>) => void, changeValue: (value: FileInfo) => void): void => {
changeType(Video)
changeValue({} as any)
}
export default Video

View File

@ -0,0 +1,7 @@
.Video {
display: none;
}
.VideoView {
max-width: 400px;
}

View File

@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import {mocked} from 'jest-mock'
import octoClient from '../../../../octoClient'
import VideoBlock from '.'
jest.mock('../../../../octoClient')
describe('components/blocksEditor/blocks/video', () => {
test('should match Display snapshot', async () => {
const mockedOcto = mocked(octoClient, true)
mockedOcto.getFileAsDataUrl.mockResolvedValue({url: 'test.jpg'})
const Component = VideoBlock.Display
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: 'test', filename: 'test-filename'}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
await screen.findByTestId('video')
expect(container).toMatchSnapshot()
})
test('should match Display snapshot with empty value', async () => {
const Component = VideoBlock.Display
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: '', filename: ''}}
onCancel={jest.fn()}
onSave={jest.fn()}
currentBoardId=''
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
const Component = VideoBlock.Input
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: 'test', filename: 'test-filename'}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot with empty input', async () => {
const Component = VideoBlock.Input
const {container} = render(
<Component
onChange={jest.fn()}
value={{file: '', filename: ''}}
onCancel={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should emit onSave on change', async () => {
const onSave = jest.fn()
const Component = VideoBlock.Input
render(
<Component
onChange={jest.fn()}
value={{file: 'test', filename: 'test-filename'}}
onCancel={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).not.toBeCalled()
const input = screen.getByTestId('video-input')
fireEvent.change(input, {target: {files: ['test-file']}})
expect(onSave).toBeCalledWith({file: 'test-file'})
})
})

View File

@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {render, screen, fireEvent, act} from '@testing-library/react'
import {mockDOM, wrapDNDIntl, mockStateStore} from '../../testUtils'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {BlockData} from './blocks/types'
import BlocksEditor from './blocksEditor'
jest.mock('draft-js/lib/generateRandomKey', () => () => '123')
describe('components/blocksEditor/blocksEditor', () => {
beforeEach(mockDOM)
const blocks: Array<BlockData<any>> = [
{id: '1', value: 'Title', contentType: 'h1'},
{id: '2', value: 'Sub title', contentType: 'h2'},
{id: '3', value: 'Sub sub title', contentType: 'h3'},
{id: '4', value: 'Some **markdown** text', contentType: 'text'},
{id: '5', value: 'Some multiline\n**markdown** text\n### With Items\n- Item 1\n- Item2\n- Item3', contentType: 'text'},
{id: '6', value: {checked: true, value: 'Checkbox'}, contentType: 'checkbox'},
]
const board1 = TestBlockFactory.createBoard()
board1.id = 'board-id-1'
const state = {
users: {
boardUsers: {
1: {username: 'abc'},
2: {username: 'd'},
3: {username: 'e'},
4: {username: 'f'},
5: {username: 'g'},
},
},
boards: {
current: 'board-id-1',
boards: {
[board1.id]: board1,
},
},
clientConfig: {
value: {},
},
}
const store = mockStateStore([], state)
test('should match snapshot on empty', async () => {
let container
await act(async () => {
const result = render(wrapDNDIntl(
<ReduxProvider store={store}>
<BlocksEditor
boardId='test-board'
onBlockCreated={jest.fn()}
onBlockModified={jest.fn()}
onBlockMoved={jest.fn()}
blocks={[]}
/>
</ReduxProvider>,
))
container = result.container
})
expect(container).toMatchSnapshot()
})
test('should match snapshot with blocks', async () => {
let container
await act(async () => {
const result = render(wrapDNDIntl(
<ReduxProvider store={store}>
<BlocksEditor
boardId='test-board'
onBlockCreated={jest.fn()}
onBlockModified={jest.fn()}
onBlockMoved={jest.fn()}
blocks={blocks}
/>
</ReduxProvider>,
))
container = result.container
})
expect(container).toMatchSnapshot()
})
test('should call onBlockCreate after introduce text and hit enter', async () => {
const onBlockCreated = jest.fn()
await act(async () => {
render(wrapDNDIntl(
<ReduxProvider store={store}>
<BlocksEditor
boardId='test-board'
onBlockCreated={onBlockCreated}
onBlockModified={jest.fn()}
onBlockMoved={jest.fn()}
blocks={[]}
/>
</ReduxProvider>,
))
})
let input = screen.getByDisplayValue('')
expect(onBlockCreated).not.toBeCalled()
fireEvent.change(input, {target: {value: '/title'}})
fireEvent.keyDown(input, {key: 'Enter'})
input = screen.getByDisplayValue('')
fireEvent.change(input, {target: {value: 'test'}})
fireEvent.keyDown(input, {key: 'Enter'})
expect(onBlockCreated).toBeCalledWith(expect.objectContaining({value: 'test'}))
})
test('should call onBlockModified after introduce text and hit enter', async () => {
const onBlockModified = jest.fn()
await act(async () => {
render(wrapDNDIntl(
<ReduxProvider store={store}>
<BlocksEditor
boardId='test-board'
onBlockCreated={jest.fn()}
onBlockModified={onBlockModified}
onBlockMoved={jest.fn()}
blocks={blocks}
/>
</ReduxProvider>,
))
const input = screen.getByTestId('checkbox-check')
expect(onBlockModified).not.toBeCalled()
fireEvent.click(input)
expect(onBlockModified).toBeCalledWith(expect.objectContaining({value: {checked: false, value: 'Checkbox'}}))
})
})
})

View File

@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useMemo} from 'react'
import {DndProvider} from 'react-dnd'
import {HTML5Backend} from 'react-dnd-html5-backend'
import Editor from './editor'
import {BlockData} from './blocks/types'
import BlockContent from './blockContent'
import * as registry from './blocks'
type Props = {
boardId?: string
onBlockCreated: (block: BlockData, afterBlock?: BlockData) => Promise<BlockData|null>
onBlockModified: (block: BlockData) => Promise<BlockData|null>
onBlockMoved: (block: BlockData, beforeBlock: BlockData|null, afterBlock: BlockData|null) => Promise<void>
blocks: BlockData[]
}
function BlocksEditor(props: Props) {
const [nextType, setNextType] = useState<string>('')
const [editing, setEditing] = useState<BlockData|null>(null)
const [afterBlock, setAfterBlock] = useState<BlockData|null>(null)
const contentOrder = useMemo(() => props.blocks.filter((b) => b.id).map((b) => b.id!), [props.blocks])
return (
<div
className='BlocksEditor'
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowUp') {
if (editing === null) {
if (afterBlock === null) {
setEditing(props.blocks[props.blocks.length - 1] || null)
} else {
setEditing(afterBlock)
}
setAfterBlock(null)
return
}
let prevBlock = null
for (const b of props.blocks) {
if (editing?.id === b.id) {
break
}
const blockType = registry.get(b.contentType)
if (blockType.editable) {
prevBlock = b
}
}
if (prevBlock) {
setEditing(prevBlock)
setAfterBlock(null)
}
} else if (e.key === 'ArrowDown') {
let currentBlock = editing
if (currentBlock === null) {
currentBlock = afterBlock
}
if (currentBlock === null) {
return
}
let nextBlock = null
let breakNext = false
for (const b of props.blocks) {
if (breakNext) {
const blockType = registry.get(b.contentType)
if (blockType.editable) {
nextBlock = b
break
}
}
if (currentBlock.id === b.id) {
breakNext = true
}
}
setEditing(nextBlock)
setAfterBlock(null)
}
}}
>
<DndProvider backend={HTML5Backend}>
{Object.values(props.blocks).map((d) => (
<div
key={d.id}
>
<BlockContent
key={d.id}
block={d}
editing={editing}
setEditing={(block) => {
setEditing(block)
setAfterBlock(null)
}}
contentOrder={contentOrder}
setAfterBlock={setAfterBlock}
onSave={async (b) => {
const newBlock = await props.onBlockModified(b)
setNextType(registry.get(b.contentType).nextType || '')
setAfterBlock(newBlock)
return newBlock
}}
onMove={props.onBlockMoved}
/>
{afterBlock && afterBlock.id === d.id && (
<Editor
initialValue=''
initialContentType={nextType}
onSave={async (b) => {
const newBlock = await props.onBlockCreated(b, afterBlock)
setNextType(registry.get(b.contentType).nextType || '')
setAfterBlock(newBlock)
return newBlock
}}
/>)}
</div>
))}
{!editing && !afterBlock && <Editor onSave={props.onBlockCreated}/>}
</DndProvider>
</div>
)
}
export default BlocksEditor

View File

@ -0,0 +1,27 @@
h1,
h2,
h3,
p {
margin: 5px;
}
.App-header {
padding: 20px 100px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react'
import ReactDOM from 'react-dom'
import {BlockData} from './blocks/types'
import BlocksEditor from './blocksEditor'
import {register} from './blocks/'
import TextDev from './blocks/text-dev'
import '@mattermost/compass-icons/css/compass-icons.css'
import '../../styles/variables.scss'
import '../../styles/main.scss'
import '../../styles/labels.scss'
import '../../styles/_markdown.scss'
import './devmain.scss'
const newID = () => Math.random().toString(36).slice(2)
register(TextDev)
const fakeData = [
{id: '1', value: 'Title', contentType: 'h1'},
{id: '2', value: 'Sub title', contentType: 'h2'},
{id: '3', value: 'Sub sub title', contentType: 'h3'},
{id: '4', value: 'Some **markdown** text', contentType: 'text'},
{id: '5', value: 'Some multiline\n**markdown** text\n### With Items\n- Item 1\n- Item2\n- Item3', contentType: 'text'},
{id: '6', value: {checked: true, value: 'Checkbox'}, contentType: 'checkbox'},
]
function App() {
//const [data, setData] = useState<BlockData[]>([])
const [data, setData] = useState<Array<BlockData<any>>>(fakeData)
return (
<div className='App'>
<header className='App-header'>
<BlocksEditor
blocks={data}
onBlockCreated={async (block: BlockData<any>, afterBlock?: BlockData<any>): Promise<BlockData|null> => {
if (block.contentType === 'text' && block.value === '') {
return null
}
const id = newID()
let newData: BlockData[] = []
const newBlock = {value: block.value, contentType: block.contentType, id}
if (block.contentType === 'image' && (typeof block.value.file !== 'string')) {
const base64String = btoa(String.fromCharCode.apply(null, (new Uint8Array(block.value.file)) as unknown as number[]))
newBlock.value.file = `data:image/jpeg;base64,${base64String}`
}
if (afterBlock) {
for (const b of data) {
newData.push(b)
if (b.id === afterBlock.id) {
newData.push(newBlock)
}
}
} else {
newData = [...data, newBlock]
}
setData(newData)
return newBlock
}}
onBlockModified={async (block: BlockData): Promise<BlockData|null> => {
const newData: BlockData[] = []
if (block.contentType === 'text' && block.value === '') {
for (const b of data) {
if (b.id !== block.id) {
newData.push(b)
}
}
setData(newData)
return block
}
for (const b of data) {
if (b.id === block.id) {
newData.push(block)
} else {
newData.push(b)
}
}
setData(newData)
return block
}}
onBlockMoved={async (block: BlockData<any>, beforeBlock: BlockData|null, afterBlock: BlockData<any>|null): Promise<void> => {
const newData: BlockData[] = []
for (const b of data) {
if (b.id !== block.id) {
if (beforeBlock && b.id === beforeBlock.id) {
newData.push(block)
}
newData.push(b)
if (afterBlock && b.id === afterBlock.id) {
newData.push(block)
}
}
}
setData(newData)
}}
/>
</header>
</div>
)
}
ReactDOM.render(<App/>, document.getElementById('focalboard-app'))

View File

@ -0,0 +1,27 @@
.Editor {
margin-left: 50px;
color: rgb(var(--center-channel-color-rgb));
background-color: rgb(var(--center-channel-bg-rgb));
.RootInput {
display: block;
}
&.with-content-type {
.RootInput {
display: none;
}
}
input {
width: 100%;
border: 1px solid hsl(0, 0%, 80%);
border-radius: 4px;
padding: 3px 6px;
outline: 0;
&:focus {
border: 2px solid #2684ff;
}
}
}

View File

@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {render, screen, fireEvent, act} from '@testing-library/react'
import {mockDOM, wrapDNDIntl, mockStateStore} from '../../testUtils'
import {TestBlockFactory} from '../../test/testBlockFactory'
import Editor from './editor'
jest.mock('draft-js/lib/generateRandomKey', () => () => '123')
describe('components/blocksEditor/editor', () => {
beforeEach(mockDOM)
const board1 = TestBlockFactory.createBoard()
board1.id = 'board-id-1'
const state = {
users: {
boardUsers: {
1: {username: 'abc'},
2: {username: 'd'},
3: {username: 'e'},
4: {username: 'f'},
5: {username: 'g'},
},
},
boards: {
current: 'board-id-1',
boards: {
[board1.id]: board1,
},
},
clientConfig: {
value: {},
},
}
const store = mockStateStore([], state)
test('should match snapshot', async () => {
let container
await act(async () => {
const result = render(wrapDNDIntl(
<ReduxProvider store={store}>
<Editor
id='block-id'
boardId='fake-board-id'
initialValue='test-value'
initialContentType='text'
onSave={jest.fn()}
/>
</ReduxProvider>,
))
container = result.container
})
expect(container).toMatchSnapshot()
})
test('should match snapshot on empty', async () => {
let container
await act(async () => {
const result = render(wrapDNDIntl(
<ReduxProvider store={store}>
<Editor
boardId='fake-board-id'
onSave={jest.fn()}
/>
</ReduxProvider>,
))
container = result.container
})
expect(container).toMatchSnapshot()
})
test('should call onSave after introduce text and hit enter', async () => {
const onSave = jest.fn()
await act(async () => {
render(wrapDNDIntl(
<ReduxProvider store={store}>
<Editor
boardId='fake-board-id'
onSave={onSave}
/>
</ReduxProvider>,
))
})
let input = screen.getByDisplayValue('')
expect(onSave).not.toBeCalled()
fireEvent.change(input, {target: {value: '/title'}})
fireEvent.keyDown(input, {key: 'Enter'})
expect(onSave).not.toBeCalled()
input = screen.getByDisplayValue('')
fireEvent.change(input, {target: {value: 'test'}})
fireEvent.keyDown(input, {key: 'Enter'})
expect(onSave).toBeCalledWith(expect.objectContaining({value: 'test'}))
})
})

View File

@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react'
import * as contentBlocks from './blocks/'
import {ContentType, BlockData} from './blocks/types'
import RootInput from './rootInput'
import './editor.scss'
type Props = {
boardId?: string
onSave: (block: BlockData) => Promise<BlockData|null>
id?: string
initialValue?: string
initialContentType?: string
}
export default function Editor(props: Props) {
const [value, setValue] = useState(props.initialValue || '')
const [currentBlockType, setCurrentBlockType] = useState<ContentType|null>(contentBlocks.get(props.initialContentType || '') || null)
useEffect(() => {
if (!currentBlockType) {
const block = contentBlocks.getByPrefix(value)
if (block) {
setValue('')
setCurrentBlockType(block)
} else if (value !== '' && !contentBlocks.isSubPrefix(value) && !value.startsWith('/')) {
setCurrentBlockType(contentBlocks.get('text'))
}
}
}, [value, currentBlockType])
const CurrentBlockInput = currentBlockType?.Input
return (
<div className='Editor'>
{currentBlockType === null &&
<RootInput
onChange={setValue}
onChangeType={setCurrentBlockType}
value={value}
onSave={async (val: string, blockType: string) => {
if (blockType === null && val === '') {
return
}
await props.onSave({value: val, contentType: blockType, id: props.id})
setValue('')
setCurrentBlockType(null)
}}
/>}
{CurrentBlockInput &&
<CurrentBlockInput
onChange={setValue}
value={value}
onCancel={() => {
setValue('')
setCurrentBlockType(null)
}}
onSave={async (val: string) => {
const newBlock = await props.onSave({value: val, contentType: currentBlockType.name, id: props.id})
setValue('')
const createdContentType = contentBlocks.get(newBlock?.contentType || '')
setCurrentBlockType(contentBlocks.get(createdContentType?.nextType || '') || null)
}}
currentBoardId={props.boardId}
/>}
</div>
)
}

View File

@ -0,0 +1,119 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import RootInput from './rootInput'
describe('components/blocksEditor/rootInput', () => {
test('should match Display snapshot', async () => {
const {container} = render(
<RootInput
onChange={jest.fn()}
value='test-value'
onChangeType={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot', async () => {
const {container} = render(
<RootInput
onChange={jest.fn()}
value='test-value'
onChangeType={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(container).toMatchSnapshot()
})
test('should match Input snapshot with menu open', async () => {
const {container} = render(
<RootInput
onChange={jest.fn()}
value=''
onChangeType={jest.fn()}
onSave={jest.fn()}
/>,
)
const input = screen.getByDisplayValue('')
fireEvent.change(input, {target: {value: '/'}})
expect(container).toMatchSnapshot()
})
test('should emit onChange event', async () => {
const onChange = jest.fn()
render(
<RootInput
onChange={onChange}
value='test-value'
onChangeType={jest.fn()}
onSave={jest.fn()}
/>,
)
expect(onChange).not.toBeCalled()
const input = screen.getByDisplayValue('test-value')
fireEvent.change(input, {target: {value: 'test-value-'}})
expect(onChange).toBeCalled()
})
test('should not emit onChangeType event when value is not empty and hit backspace', async () => {
const onChangeType = jest.fn()
render(
<RootInput
onChange={jest.fn()}
value='test-value'
onChangeType={onChangeType}
onSave={jest.fn()}
/>,
)
expect(onChangeType).not.toBeCalled()
const input = screen.getByDisplayValue('test-value')
fireEvent.keyDown(input, {key: 'Backspace'})
expect(onChangeType).not.toBeCalled()
})
test('should emit onSave event hit enter', async () => {
const onSave = jest.fn()
render(
<RootInput
onChange={jest.fn()}
value='test-value'
onChangeType={jest.fn()}
onSave={onSave}
/>,
)
expect(onSave).not.toBeCalled()
const input = screen.getByDisplayValue('test-value')
fireEvent.keyDown(input, {key: 'Enter'})
expect(onSave).toBeCalled()
})
test('should emit onChangeType event on menu option selected', async () => {
const onChangeType = jest.fn()
render(
<RootInput
onChange={jest.fn()}
value=''
onChangeType={onChangeType}
onSave={jest.fn()}
/>,
)
const input = screen.getByDisplayValue('')
fireEvent.change(input, {target: {value: '/'}})
const option = screen.getByText('/title Creates a new Title block.')
fireEvent.click(option)
expect(onChangeType).toBeCalledWith(expect.objectContaining({name: 'h1'}))
})
})

View File

@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react'
import Select from 'react-select'
import {CSSObject} from '@emotion/serialize'
import {getSelectBaseStyle} from '../../theme'
import * as registry from './blocks/'
import {ContentType} from './blocks/types'
type Props = {
onChange: (value: string) => void
onChangeType: (blockType: ContentType) => void
onSave: (value: string, blockType: string) => void
value: string
}
const baseStyles = getSelectBaseStyle()
const styles = {
...baseStyles,
control: (provided: CSSObject): CSSObject => ({
...provided,
width: '100%',
height: '100%',
display: 'flex',
background: 'rgb(var(--center-channel-bg-rgb))',
color: 'rgb(var(--center-channel-color-rgb))',
flexDirection: 'row',
}),
input: (provided: CSSObject): CSSObject => ({
...provided,
background: 'rgb(var(--center-channel-bg-rgb))',
color: 'rgb(var(--center-channel-color-rgb))',
}),
menu: (provided: CSSObject): CSSObject => ({
...provided,
minWidth: '100%',
width: 'max-content',
background: 'rgb(var(--center-channel-bg-rgb))',
left: '0',
marginBottom: '0',
}),
menuPortal: (provided: CSSObject): CSSObject => ({
...provided,
zIndex: 999,
}),
}
export default function RootInput(props: Props) {
const [showMenu, setShowMenu] = useState(false)
return (
<Select
styles={styles}
components={{DropdownIndicator: () => null, IndicatorSeparator: () => null}}
className='RootInput'
placeholder={'Introduce your text or your slash command'}
autoFocus={true}
menuIsOpen={showMenu}
menuPortalTarget={document.getElementById('focalboard-root-portal')}
menuPosition={'fixed'}
options={registry.list()}
getOptionValue={(ct: ContentType) => ct.slashCommand}
getOptionLabel={(ct: ContentType) => ct.slashCommand + ' Creates a new ' + ct.displayName + ' block.'}
filterOption={(option: any, inputValue: string): boolean => {
return inputValue.startsWith(option.value) || option.value.startsWith(inputValue)
}}
inputValue={props.value}
onInputChange={(inputValue: string) => {
props.onChange(inputValue)
if (inputValue.startsWith('/')) {
setShowMenu(true)
} else {
setShowMenu(false)
}
}}
onChange={(ct: ContentType|null) => {
if (ct) {
const args = props.value.split(' ').slice(1)
ct.runSlashCommand(props.onChangeType, props.onChange, ...args)
}
}}
onBlur={() => {
const command = props.value.trimStart().split(' ')[0]
const block = registry.getBySlashCommandPrefix(command)
if (command === '' || !block) {
props.onSave(props.value, 'text')
props.onChange('')
}
}}
onFocus={(e: React.FocusEvent) => {
const target = e.currentTarget
target.scrollIntoView({block: 'center'})
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
props.onSave('', 'text')
props.onChange('')
}
if (e.key === 'Enter') {
const command = props.value.trimStart().split(' ')[0]
const block = registry.getBySlashCommandPrefix(command)
if (command === '' || !block) {
e.preventDefault()
e.stopPropagation()
props.onSave(props.value, 'text')
props.onChange('')
}
}
}}
/>
)
}

View File

@ -225,4 +225,8 @@
&__limited-button {
margin-top: 24px;
}
.BlocksEditor {
width: 100%;
}
}

View File

@ -153,6 +153,11 @@ describe('components/cardDetail/CardDetail', () => {
'user-id-1': {username: 'username_1'},
},
},
clientConfig: {
value: {
featureFlags: {},
},
},
})
const component = (
@ -540,6 +545,11 @@ describe('components/cardDetail/CardDetail', () => {
},
current: limitedCard.id,
},
clientConfig: {
value: {
featureFlags: {},
},
},
})
const component = (

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState, Fragment} from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import React, {useCallback, useEffect, useRef, useState, Fragment, useMemo} from 'react'
import {FormattedMessage, useIntl, IntlShape} from 'react-intl'
import {BlockIcons} from '../../blockIcons'
import {Card} from '../../blocks/card'
@ -9,7 +9,10 @@ import {BoardView} from '../../blocks/boardView'
import {Board} from '../../blocks/board'
import {CommentBlock} from '../../blocks/commentBlock'
import {ContentBlock} from '../../blocks/contentBlock'
import {Block, ContentBlockTypes, createBlock} from '../../blocks/block'
import mutator from '../../mutator'
import octoClient from '../../octoClient'
import {Utils} from '../../utils'
import Button from '../../widgets/buttons/button'
import {Focusable} from '../../widgets/editable'
import EditableArea from '../../widgets/editableArea'
@ -18,10 +21,15 @@ import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../teleme
import BlockIconSelector from '../blockIconSelector'
import {useAppDispatch} from '../../store/hooks'
import {setCurrent as setCurrentCard} from '../../store/cards'
import {useAppDispatch, useAppSelector} from '../../store/hooks'
import {updateCards, setCurrent as setCurrentCard} from '../../store/cards'
import {updateContents} from '../../store/contents'
import {Permission} from '../../constants'
import {useHasCurrentBoardPermissions} from '../../hooks/permissions'
import BlocksEditor from '../blocksEditor/blocksEditor'
import {BlockData} from '../blocksEditor/blocks/types'
import {ClientConfig} from '../../config/clientConfig'
import {getClientConfig} from '../../store/clientConfig'
import CardSkeleton from '../../svg/card-skeleton'
@ -49,6 +57,42 @@ type Props = {
onClose: () => void
}
async function addBlockNewEditor(card: Card, intl: IntlShape, title: string, fields: any, contentType: ContentBlockTypes, afterBlockId: string, dispatch: any): Promise<Block> {
const block = createBlock()
block.parentId = card.id
block.boardId = card.boardId
block.title = title
block.type = contentType
block.fields = {...block.fields, ...fields}
const description = intl.formatMessage({id: 'CardDetail.addCardText', defaultMessage: 'add card text'})
const afterRedo = async (newBlock: Block) => {
const contentOrder = card.fields.contentOrder.slice()
if (afterBlockId) {
const idx = contentOrder.indexOf(afterBlockId)
if (idx === -1) {
contentOrder.push(newBlock.id)
} else {
contentOrder.splice(idx + 1, 0, newBlock.id)
}
} else {
contentOrder.push(newBlock.id)
}
await octoClient.patchBlock(card.boardId, card.id, {updatedFields: {contentOrder}})
dispatch(updateCards([{...card, fields: {...card.fields, contentOrder}}]))
}
const beforeUndo = async () => {
const contentOrder = card.fields.contentOrder.slice()
await octoClient.patchBlock(card.boardId, card.id, {updatedFields: {contentOrder}})
}
const newBlock = await mutator.insertBlock(block.boardId, block, description, afterRedo, beforeUndo)
dispatch(updateContents([newBlock]))
return newBlock
}
const CardDetail = (props: Props): JSX.Element|null => {
const {card, comments} = props
const {limited} = card
@ -67,6 +111,9 @@ const CardDetail = (props: Props): JSX.Element|null => {
saveTitleRef.current = saveTitle
const intl = useIntl()
const clientConfig = useAppSelector<ClientConfig>(getClientConfig)
const newBoardsEditor = clientConfig?.featureFlags?.newBoardsEditor || false
useImagePaste(props.board.id, card.id, card.fields.contentOrder)
useEffect(() => {
@ -103,6 +150,44 @@ const CardDetail = (props: Props): JSX.Element|null => {
return null
}
const blocks = useMemo(() => props.contents.flatMap((value: Block | Block[]): BlockData<any> => {
const v: Block = Array.isArray(value) ? value[0] : value
let data: any = v?.title
if (v?.type === 'image') {
data = {
file: v?.fields.fileId,
}
}
if (v?.type === 'attachment') {
data = {
file: v?.fields.fileId,
filename: v?.fields.filename,
}
}
if (v?.type === 'video') {
data = {
file: v?.fields.fileId,
filename: v?.fields.filename,
}
}
if (v?.type === 'checkbox') {
data = {
value: v?.title,
checked: v?.fields.value,
}
}
return {
id: v?.id,
value: data,
contentType: v?.type,
}
}), [props.contents])
return (
<>
<div className={`CardDetail content${limited ? ' is-limited' : ''}`}>
@ -216,14 +301,84 @@ const CardDetail = (props: Props): JSX.Element|null => {
{/* Content blocks */}
{!limited && <div className='CardDetail content fullwidth content-blocks'>
<CardDetailProvider card={card}>
<CardDetailContents
card={props.card}
contents={props.contents}
readonly={props.readonly || !canEditBoardCards}
/>
{!props.readonly && canEditBoardCards && <CardDetailContentsMenu/>}
</CardDetailProvider>
{newBoardsEditor && (
<BlocksEditor
boardId={card.boardId}
blocks={blocks}
onBlockCreated={async (block: any, afterBlock: any): Promise<BlockData|null> => {
if (block.contentType === 'text' && block.value === '') {
return null
}
let newBlock: Block
if (block.contentType === 'checkbox') {
newBlock = await addBlockNewEditor(card, intl, block.value.value, {value: block.value.checked}, block.contentType, afterBlock?.id, dispatch)
} else if (block.contentType === 'image' || block.contentType === 'attachment' || block.contentType === 'video') {
const newFileId = await octoClient.uploadFile(card.boardId, block.value.file)
newBlock = await addBlockNewEditor(card, intl, '', {fileId: newFileId, filename: block.value.filename}, block.contentType, afterBlock?.id, dispatch)
} else {
newBlock = await addBlockNewEditor(card, intl, block.value, {}, block.contentType, afterBlock?.id, dispatch)
}
return {...block, id: newBlock.id}
}}
onBlockModified={async (block: any): Promise<BlockData<any>|null> => {
const originalContentBlock = props.contents.flatMap((b) => b).find((b) => b.id === block.id)
if (!originalContentBlock) {
return null
}
if (block.contentType === 'text' && block.value === '') {
const description = intl.formatMessage({id: 'ContentBlock.DeleteAction', defaultMessage: 'delete'})
mutator.deleteBlock(originalContentBlock, description)
return null
}
const newBlock = {
...originalContentBlock,
title: block.value,
}
if (block.contentType === 'checkbox') {
newBlock.title = block.value.value
newBlock.fields = {...newBlock.fields, value: block.value.checked}
}
mutator.updateBlock(card.boardId, newBlock, originalContentBlock, intl.formatMessage({id: 'ContentBlock.editCardText', defaultMessage: 'edit card content'}))
return block
}}
onBlockMoved={async (block: BlockData, beforeBlock: BlockData|null, afterBlock: BlockData|null): Promise<void> => {
if (block.id) {
const idx = card.fields.contentOrder.indexOf(block.id)
let sourceBlockId: string
let sourceWhere: 'after'|'before'
if (idx === -1) {
Utils.logError('Unable to find the block id in the order of the current block')
return
}
if (idx === 0) {
sourceBlockId = card.fields.contentOrder[1] as string
sourceWhere = 'before'
} else {
sourceBlockId = card.fields.contentOrder[idx - 1] as string
sourceWhere = 'after'
}
if (afterBlock && afterBlock.id) {
await mutator.moveContentBlock(block.id, afterBlock.id, 'after', sourceBlockId, sourceWhere, intl.formatMessage({id: 'ContentBlock.moveBlock', defaultMessage: 'move card content'}))
return
}
if (beforeBlock && beforeBlock.id) {
await mutator.moveContentBlock(block.id, beforeBlock.id, 'before', sourceBlockId, sourceWhere, intl.formatMessage({id: 'ContentBlock.moveBlock', defaultMessage: 'move card content'}))
}
}
}}
/>)}
{!newBoardsEditor && (
<CardDetailProvider card={card}>
<CardDetailContents
card={props.card}
contents={props.contents}
readonly={props.readonly || !canEditBoardCards}
/>
{!props.readonly && canEditBoardCards && <CardDetailContentsMenu/>}
</CardDetailProvider>)}
</div>}
</>
)

View File

@ -5,10 +5,12 @@ import '../content/imageElement'
import '../content/dividerElement'
import '../content/checkboxElement'
import {contentBlockTypes} from '../../blocks/block'
import {ContentBlockTypes} from '../../blocks/block'
import {contentRegistry} from './contentRegistry'
const contentBlockTypes = ['text', 'image', 'divider', 'checkbox'] as ContentBlockTypes[]
describe('components/content/ContentRegistry', () => {
test('have all contentTypes', () => {
expect(contentRegistry.contentTypes).toEqual(contentBlockTypes)

View File

@ -17,11 +17,15 @@ type Props = {
onChange?: (text: string) => void
onFocus?: () => void
onBlur?: (text: string) => void
onKeyDown?: (e: React.KeyboardEvent) => void
onEditorCancel?: () => void
autofocus?: boolean
saveOnEnter?: boolean
}
const MarkdownEditor = (props: Props): JSX.Element => {
const {placeholderText, onFocus, onBlur, onChange, text, id} = props
const [isEditing, setIsEditing] = useState(false)
const {placeholderText, onFocus, onEditorCancel, onBlur, onChange, text, id, saveOnEnter} = props
const [isEditing, setIsEditing] = useState(Boolean(props.autofocus))
const html: string = Utils.htmlFromMarkdown(text || placeholderText || '')
const previewElement = (
@ -55,9 +59,11 @@ const MarkdownEditor = (props: Props): JSX.Element => {
id={id}
onChange={onChange}
onFocus={onFocus}
onEditorCancel={onEditorCancel}
onBlur={editorOnBlur}
initialText={text}
isEditing={isEditing}
saveOnEnter={saveOnEnter}
/>
</Suspense>
)

View File

@ -52,9 +52,11 @@ type Props = {
onChange?: (text: string) => void
onFocus?: () => void
onBlur?: (text: string) => void
onEditorCancel?: () => void
initialText?: string
id?: string
isEditing: boolean
saveOnEnter?: boolean
}
const MarkdownEditorInput = (props: Props): ReactElement => {
@ -198,6 +200,10 @@ const MarkdownEditorInput = (props: Props): ReactElement => {
return 'editor-blur'
}
if (e.key === 'Backspace') {
return 'backspace'
}
if (getDefaultKeyBinding(e) === 'undo') {
return 'editor-undo'
}
@ -229,8 +235,15 @@ const MarkdownEditorInput = (props: Props): ReactElement => {
return 'handled'
}
if (command === 'backspace') {
if (props.onEditorCancel && editorState.getCurrentContent().getPlainText().length === 0) {
props.onEditorCancel()
return 'handled'
}
}
return 'not-handled'
}, [])
}, [props.onEditorCancel, editorState])
const onEditorStateBlur = useCallback(() => {
if (confirmAddUser) {
@ -238,7 +251,7 @@ const MarkdownEditorInput = (props: Props): ReactElement => {
}
const text = editorState.getCurrentContent().getPlainText()
onBlur && onBlur(text)
}, [editorState, onBlur])
}, [editorState.getCurrentContent().getPlainText(), onBlur, confirmAddUser])
const onMentionPopoverOpenChange = useCallback((open: boolean) => {
setIsMentionPopoverOpen(open)
@ -258,9 +271,23 @@ const MarkdownEditorInput = (props: Props): ReactElement => {
const className = 'MarkdownEditorInput'
const handleReturn = (e: any, state: EditorState): DraftHandleValue => {
if (!e.shiftKey) {
const text = state.getCurrentContent().getPlainText()
onBlur && onBlur(text)
return 'handled'
}
return 'not-handled'
}
return (
<div
className={className}
onKeyDown={(e: React.KeyboardEvent) => {
if (isMentionPopoverOpen || isEmojiPopoverOpen) {
e.stopPropagation()
}
}}
>
<Editor
editorKey={id}
@ -272,6 +299,7 @@ const MarkdownEditorInput = (props: Props): ReactElement => {
onFocus={onFocus}
keyBindingFn={customKeyBindingFn}
handleKeyCommand={handleKeyCommand}
handleReturn={props.saveOnEnter ? handleReturn : undefined}
/>
<MentionSuggestions
open={isMentionPopoverOpen}

View File

@ -1059,6 +1059,19 @@ class Mutator {
)
}
async moveContentBlock(blockId: string, dstBlockId: string, where: 'after'|'before', srcBlockId: string, srcWhere: 'after'|'before', description: string): Promise<void> {
return undoManager.perform(
async () => {
await octoClient.moveBlockTo(blockId, where, dstBlockId)
},
async () => {
await octoClient.moveBlockTo(blockId, srcWhere, srcBlockId)
},
description,
this.undoGroupId,
)
}
async addBoardFromTemplate(
teamId: string,
intl: IntlShape,

View File

@ -954,6 +954,14 @@ class OctoClient {
return (await this.getJson(response, {})) as TopBoardResponse
}
async moveBlockTo(blockId: string, where: 'before'|'after', dstBlockId: string): Promise<Response> {
return fetch(`${this.getBaseURL()}/api/v2/content-blocks/${blockId}/moveto/${where}/${dstBlockId}`, {
method: 'POST',
headers: this.headers(),
body: '{}',
})
}
}
const octoClient = new OctoClient()

View File

@ -11,6 +11,9 @@ import {createCheckboxBlock} from './blocks/checkboxBlock'
import {createDividerBlock} from './blocks/dividerBlock'
import {createImageBlock} from './blocks/imageBlock'
import {createTextBlock} from './blocks/textBlock'
import {createH1Block} from './blocks/h1Block'
import {createH2Block} from './blocks/h2Block'
import {createH3Block} from './blocks/h3Block'
import {FilterCondition} from './blocks/filterClause'
import {Utils} from './utils'
@ -20,6 +23,9 @@ class OctoUtils {
case 'view': { return createBoardView(block) }
case 'card': { return createCard(block) }
case 'text': { return createTextBlock(block) }
case 'h1': { return createH1Block(block) }
case 'h2': { return createH2Block(block) }
case 'h3': { return createH3Block(block) }
case 'image': { return createImageBlock(block) }
case 'divider': { return createDividerBlock(block) }
case 'comment': { return createCommentBlock(block) }

View File

@ -5,6 +5,7 @@
border-radius: var(--default-rad);
color: rgb(var(--center-channel-color-rgb));
display: flex;
min-width: 180px;
> .Label {
margin: 0 10px;
@ -22,6 +23,10 @@
border-radius: var(--default-rad);
max-width: 100%;
.Label-text {
flex-grow: 1;
}
.IconButton.delete-value {
@include z-index(value-selector-delete);
width: 16px;

View File

@ -162,6 +162,7 @@ function ValueSelector(props: Props): JSX.Element {
captureMenuScroll={true}
maxMenuHeight={1200}
isMulti={props.isMulti}
menuIsOpen={true}
isClearable={true}
styles={valueSelectorStyle}
formatOptionLabel={(option: IPropertyOption, meta: FormatOptionLabelMeta<IPropertyOption>) => (

48
webapp/webpack.editor.js Normal file
View File

@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const merge = require('webpack-merge');
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const makeCommonConfig = require('./webpack.common.js');
const commonConfig = makeCommonConfig();
const config = merge.merge(commonConfig, {
mode: 'development',
devtool: 'inline-source-map',
optimization: {
minimize: false,
},
devServer: {
port: 9000,
open: "/editor.html",
},
entry: ['./src/components/blocksEditor/devmain.tsx'],
plugins: [
new CopyPlugin({
patterns: [
{from: path.resolve(__dirname, 'static'), to: 'static'},
],
}),
new HtmlWebpackPlugin({
inject: true,
title: 'Focalboard',
chunks: ['main'],
template: 'html-templates/deveditor.ejs',
filename: 'editor.html',
publicPath: '/',
hash: true,
}),
],
});
module.exports = [
merge.merge(config, {
devtool: 'source-map',
output: {
devtoolNamespace: 'focalboard',
},
}),
];