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:
parent
93698c9574
commit
f915a20c64
3
mattermost-plugin/server/manifest.go
generated
3
mattermost-plugin/server/manifest.go
generated
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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
|
||||
|
103
server/api/content_blocks.go
Normal file
103
server/api/content_blocks.go
Normal 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()
|
||||
}
|
81
server/app/content_blocks.go
Normal file
81
server/app/content_blocks.go
Normal 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
|
||||
}
|
191
server/app/content_blocks_test.go
Normal file
191
server/app/content_blocks_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
182
server/integrationtests/content_blocks_test.go
Normal file
182
server/integrationtests/content_blocks_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
21
webapp/html-templates/deveditor.ejs
Normal file
21
webapp/html-templates/deveditor.ejs
Normal 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
2473
webapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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": {
|
||||
|
@ -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
|
||||
|
18
webapp/src/blocks/h1Block.tsx
Normal file
18
webapp/src/blocks/h1Block.tsx
Normal 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}
|
||||
|
18
webapp/src/blocks/h2Block.tsx
Normal file
18
webapp/src/blocks/h2Block.tsx
Normal 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}
|
||||
|
18
webapp/src/blocks/h3Block.tsx
Normal file
18
webapp/src/blocks/h3Block.tsx
Normal 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}
|
||||
|
@ -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>
|
||||
`;
|
@ -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>
|
||||
`;
|
@ -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>
|
||||
`;
|
@ -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>
|
||||
`;
|
31
webapp/src/components/blocksEditor/blockContent.scss
Normal file
31
webapp/src/components/blocksEditor/blockContent.scss
Normal 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;
|
||||
}
|
||||
}
|
162
webapp/src/components/blocksEditor/blockContent.test.tsx
Normal file
162
webapp/src/components/blocksEditor/blockContent.test.tsx
Normal 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'}))
|
||||
})
|
||||
})
|
124
webapp/src/components/blocksEditor/blockContent.tsx
Normal file
124
webapp/src/components/blocksEditor/blockContent.tsx
Normal 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
|
@ -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>
|
||||
`;
|
@ -0,0 +1,10 @@
|
||||
.Attachment {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.AttachmentView {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
@ -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'})
|
||||
})
|
||||
})
|
@ -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
|
@ -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>
|
||||
`;
|
@ -0,0 +1,17 @@
|
||||
.Checkbox {
|
||||
display: flex;
|
||||
|
||||
.inputCheck {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.inputText {
|
||||
outline: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.CheckboxView {
|
||||
display: flex;
|
||||
}
|
@ -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})
|
||||
})
|
||||
})
|
92
webapp/src/components/blocksEditor/blocks/checkbox/index.tsx
Normal file
92
webapp/src/components/blocksEditor/blocks/checkbox/index.tsx
Normal 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
|
@ -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 />`;
|
@ -0,0 +1,4 @@
|
||||
.Divider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
}
|
@ -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()
|
||||
})
|
||||
})
|
30
webapp/src/components/blocksEditor/blocks/divider/index.tsx
Normal file
30
webapp/src/components/blocksEditor/blocks/divider/index.tsx
Normal 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
|
@ -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>
|
||||
`;
|
4
webapp/src/components/blocksEditor/blocks/h1/h1.scss
Normal file
4
webapp/src/components/blocksEditor/blocks/h1/h1.scss
Normal file
@ -0,0 +1,4 @@
|
||||
.H1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
109
webapp/src/components/blocksEditor/blocks/h1/h1.test.tsx
Normal file
109
webapp/src/components/blocksEditor/blocks/h1/h1.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
56
webapp/src/components/blocksEditor/blocks/h1/index.tsx
Normal file
56
webapp/src/components/blocksEditor/blocks/h1/index.tsx
Normal 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
|
@ -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>
|
||||
`;
|
4
webapp/src/components/blocksEditor/blocks/h2/h2.scss
Normal file
4
webapp/src/components/blocksEditor/blocks/h2/h2.scss
Normal file
@ -0,0 +1,4 @@
|
||||
.H2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
109
webapp/src/components/blocksEditor/blocks/h2/h2.test.tsx
Normal file
109
webapp/src/components/blocksEditor/blocks/h2/h2.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
56
webapp/src/components/blocksEditor/blocks/h2/index.tsx
Normal file
56
webapp/src/components/blocksEditor/blocks/h2/index.tsx
Normal 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
|
@ -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>
|
||||
`;
|
4
webapp/src/components/blocksEditor/blocks/h3/h3.scss
Normal file
4
webapp/src/components/blocksEditor/blocks/h3/h3.scss
Normal file
@ -0,0 +1,4 @@
|
||||
.H3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
109
webapp/src/components/blocksEditor/blocks/h3/h3.test.tsx
Normal file
109
webapp/src/components/blocksEditor/blocks/h3/h3.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
56
webapp/src/components/blocksEditor/blocks/h3/index.tsx
Normal file
56
webapp/src/components/blocksEditor/blocks/h3/index.tsx
Normal 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
|
@ -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>
|
||||
`;
|
@ -0,0 +1,7 @@
|
||||
.Image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ImageView {
|
||||
max-width: 400px;
|
||||
}
|
@ -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'})
|
||||
})
|
||||
})
|
85
webapp/src/components/blocksEditor/blocks/image/index.tsx
Normal file
85
webapp/src/components/blocksEditor/blocks/image/index.tsx
Normal 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
|
76
webapp/src/components/blocksEditor/blocks/index.tsx
Normal file
76
webapp/src/components/blocksEditor/blocks/index.tsx
Normal 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)
|
@ -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>
|
||||
`;
|
@ -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
|
@ -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()
|
||||
})
|
||||
})
|
@ -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>
|
||||
`;
|
62
webapp/src/components/blocksEditor/blocks/quote/index.tsx
Normal file
62
webapp/src/components/blocksEditor/blocks/quote/index.tsx
Normal 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
|
@ -0,0 +1,7 @@
|
||||
.Editor .Quote input {
|
||||
width: calc(100% - 80px);
|
||||
}
|
||||
|
||||
.Quote {
|
||||
width: 100%;
|
||||
}
|
109
webapp/src/components/blocksEditor/blocks/quote/quote.test.tsx
Normal file
109
webapp/src/components/blocksEditor/blocks/quote/quote.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
55
webapp/src/components/blocksEditor/blocks/text-dev/index.tsx
Normal file
55
webapp/src/components/blocksEditor/blocks/text-dev/index.tsx
Normal 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
|
@ -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>
|
||||
`;
|
55
webapp/src/components/blocksEditor/blocks/text/index.tsx
Normal file
55
webapp/src/components/blocksEditor/blocks/text/index.tsx
Normal 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
|
6
webapp/src/components/blocksEditor/blocks/text/text.scss
Normal file
6
webapp/src/components/blocksEditor/blocks/text/text.scss
Normal file
@ -0,0 +1,6 @@
|
||||
.TextContent {
|
||||
width: 100%;
|
||||
border: 2px solid #2684ff;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
76
webapp/src/components/blocksEditor/blocks/text/text.test.tsx
Normal file
76
webapp/src/components/blocksEditor/blocks/text/text.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
27
webapp/src/components/blocksEditor/blocks/types.tsx
Normal file
27
webapp/src/components/blocksEditor/blocks/types.tsx
Normal 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
|
||||
}
|
@ -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>
|
||||
`;
|
81
webapp/src/components/blocksEditor/blocks/video/index.tsx
Normal file
81
webapp/src/components/blocksEditor/blocks/video/index.tsx
Normal 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
|
@ -0,0 +1,7 @@
|
||||
.Video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.VideoView {
|
||||
max-width: 400px;
|
||||
}
|
@ -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'})
|
||||
})
|
||||
})
|
139
webapp/src/components/blocksEditor/blocksEditor.test.tsx
Normal file
139
webapp/src/components/blocksEditor/blocksEditor.test.tsx
Normal 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'}}))
|
||||
})
|
||||
})
|
||||
})
|
123
webapp/src/components/blocksEditor/blocksEditor.tsx
Normal file
123
webapp/src/components/blocksEditor/blocksEditor.tsx
Normal 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
|
27
webapp/src/components/blocksEditor/devmain.scss
Normal file
27
webapp/src/components/blocksEditor/devmain.scss
Normal 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;
|
||||
}
|
110
webapp/src/components/blocksEditor/devmain.tsx
Normal file
110
webapp/src/components/blocksEditor/devmain.tsx
Normal 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'))
|
27
webapp/src/components/blocksEditor/editor.scss
Normal file
27
webapp/src/components/blocksEditor/editor.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
102
webapp/src/components/blocksEditor/editor.test.tsx
Normal file
102
webapp/src/components/blocksEditor/editor.test.tsx
Normal 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'}))
|
||||
})
|
||||
})
|
71
webapp/src/components/blocksEditor/editor.tsx
Normal file
71
webapp/src/components/blocksEditor/editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
119
webapp/src/components/blocksEditor/rootInput.test.tsx
Normal file
119
webapp/src/components/blocksEditor/rootInput.test.tsx
Normal 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'}))
|
||||
})
|
||||
})
|
116
webapp/src/components/blocksEditor/rootInput.tsx
Normal file
116
webapp/src/components/blocksEditor/rootInput.tsx
Normal 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('')
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -225,4 +225,8 @@
|
||||
&__limited-button {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.BlocksEditor {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
@ -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 = (
|
||||
|
@ -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>}
|
||||
</>
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
|
@ -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) }
|
||||
|
@ -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;
|
||||
|
@ -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
48
webapp/webpack.editor.js
Normal 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',
|
||||
},
|
||||
}),
|
||||
];
|
Loading…
x
Reference in New Issue
Block a user