You've already forked focalboard
							
							
				mirror of
				https://github.com/mattermost/focalboard.git
				synced 2025-10-31 00:17: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:
		
							
								
								
									
										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', | ||||
|         }, | ||||
|     }), | ||||
| ]; | ||||
		Reference in New Issue
	
	Block a user