You've already forked focalboard
							
							
				mirror of
				https://github.com/mattermost/focalboard.git
				synced 2025-10-31 00:17:42 +02:00 
			
		
		
		
	File attachment in the card (#4053)
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
This commit is contained in:
		| @@ -107,6 +107,11 @@ func (b *BoardsApp) OnConfigurationChange() error { | ||||
| 		showFullName = *mmconfig.PrivacySettings.ShowFullName | ||||
| 	} | ||||
| 	b.server.Config().ShowFullName = showFullName | ||||
| 	maxFileSize := int64(0) | ||||
| 	if mmconfig.FileSettings.MaxFileSize != nil { | ||||
| 		maxFileSize = *mmconfig.FileSettings.MaxFileSize | ||||
| 	} | ||||
| 	b.server.Config().MaxFileSize = maxFileSize | ||||
|  | ||||
| 	b.server.UpdateAppConfig() | ||||
| 	b.wsPluginAdapter.BroadcastConfigChange(*b.server.App().GetClientConfig()) | ||||
|   | ||||
| @@ -5,7 +5,6 @@ import ( | ||||
| 	"errors" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| @@ -13,7 +12,9 @@ import ( | ||||
|  | ||||
| 	"github.com/gorilla/mux" | ||||
| 	"github.com/mattermost/focalboard/server/model" | ||||
|  | ||||
| 	"github.com/mattermost/focalboard/server/services/audit" | ||||
| 	mmModel "github.com/mattermost/mattermost-server/v6/model" | ||||
|  | ||||
| 	"github.com/mattermost/mattermost-server/v6/shared/mlog" | ||||
| ) | ||||
| @@ -35,9 +36,19 @@ func FileUploadResponseFromJSON(data io.Reader) (*FileUploadResponse, error) { | ||||
| 	return &fileUploadResponse, nil | ||||
| } | ||||
|  | ||||
| func FileInfoResponseFromJSON(data io.Reader) (*mmModel.FileInfo, error) { | ||||
| 	var fileInfo mmModel.FileInfo | ||||
|  | ||||
| 	if err := json.NewDecoder(data).Decode(&fileInfo); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &fileInfo, nil | ||||
| } | ||||
|  | ||||
| func (a *API) registerFilesRoutes(r *mux.Router) { | ||||
| 	// Files API | ||||
| 	r.HandleFunc("/files/teams/{teamID}/{boardID}/{filename}", a.attachSession(a.handleServeFile, false)).Methods("GET") | ||||
| 	r.HandleFunc("/files/teams/{teamID}/{boardID}/{filename}/info", a.attachSession(a.getFileInfo, false)).Methods("GET") | ||||
| 	r.HandleFunc("/teams/{teamID}/{boardID}/files", a.sessionRequired(a.handleUploadFile)).Methods("POST") | ||||
| } | ||||
|  | ||||
| @@ -108,19 +119,6 @@ func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) { | ||||
| 	auditRec.AddMeta("teamID", board.TeamID) | ||||
| 	auditRec.AddMeta("filename", filename) | ||||
|  | ||||
| 	contentType := "image/jpg" | ||||
|  | ||||
| 	fileExtension := strings.ToLower(filepath.Ext(filename)) | ||||
| 	if fileExtension == "png" { | ||||
| 		contentType = "image/png" | ||||
| 	} | ||||
|  | ||||
| 	if fileExtension == "gif" { | ||||
| 		contentType = "image/gif" | ||||
| 	} | ||||
|  | ||||
| 	w.Header().Set("Content-Type", contentType) | ||||
|  | ||||
| 	fileInfo, err := a.app.GetFileInfo(filename) | ||||
| 	if err != nil && !model.IsErrNotFound(err) { | ||||
| 		a.errorResponse(w, r, err) | ||||
| @@ -172,6 +170,80 @@ func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) { | ||||
| 	auditRec.Success() | ||||
| } | ||||
|  | ||||
| func (a *API) getFileInfo(w http.ResponseWriter, r *http.Request) { | ||||
| 	// swagger:operation GET /files/teams/{teamID}/{boardID}/{filename}/info getFile | ||||
| 	// | ||||
| 	// Returns the metadata of an uploaded file | ||||
| 	// | ||||
| 	// --- | ||||
| 	// produces: | ||||
| 	// - application/json | ||||
| 	// parameters: | ||||
| 	// - name: teamID | ||||
| 	//   in: path | ||||
| 	//   description: Team ID | ||||
| 	//   required: true | ||||
| 	//   type: string | ||||
| 	// - name: boardID | ||||
| 	//   in: path | ||||
| 	//   description: Board ID | ||||
| 	//   required: true | ||||
| 	//   type: string | ||||
| 	// - name: filename | ||||
| 	//   in: path | ||||
| 	//   description: name of the file | ||||
| 	//   required: true | ||||
| 	//   type: string | ||||
| 	// security: | ||||
| 	// - BearerAuth: [] | ||||
| 	// responses: | ||||
| 	//   '200': | ||||
| 	//     description: success | ||||
| 	//   '404': | ||||
| 	//     description: file not found | ||||
| 	//   default: | ||||
| 	//     description: internal error | ||||
| 	//     schema: | ||||
| 	//       "$ref": "#/definitions/ErrorResponse" | ||||
|  | ||||
| 	vars := mux.Vars(r) | ||||
| 	boardID := vars["boardID"] | ||||
| 	teamID := vars["teamID"] | ||||
| 	filename := vars["filename"] | ||||
| 	userID := getUserID(r) | ||||
|  | ||||
| 	hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID) | ||||
| 	if userID == "" && !hasValidReadToken { | ||||
| 		a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board")) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if !hasValidReadToken && !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { | ||||
| 		a.errorResponse(w, r, model.NewErrPermission("access denied to board")) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	auditRec := a.makeAuditRecord(r, "getFile", audit.Fail) | ||||
| 	defer a.audit.LogRecord(audit.LevelRead, auditRec) | ||||
| 	auditRec.AddMeta("boardID", boardID) | ||||
| 	auditRec.AddMeta("teamID", teamID) | ||||
| 	auditRec.AddMeta("filename", filename) | ||||
|  | ||||
| 	fileInfo, err := a.app.GetFileInfo(filename) | ||||
| 	if err != nil && !model.IsErrNotFound(err) { | ||||
| 		a.errorResponse(w, r, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	data, err := json.Marshal(fileInfo) | ||||
| 	if err != nil { | ||||
| 		a.errorResponse(w, r, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	jsonBytesResponse(w, http.StatusOK, data) | ||||
| } | ||||
|  | ||||
| func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) { | ||||
| 	// swagger:operation POST /teams/{teamID}/boards/{boardID}/files uploadFile | ||||
| 	// | ||||
|   | ||||
| @@ -11,5 +11,6 @@ func (a *App) GetClientConfig() *model.ClientConfig { | ||||
| 		EnablePublicSharedBoards: a.config.EnablePublicSharedBoards, | ||||
| 		TeammateNameDisplay:      a.config.TeammateNameDisplay, | ||||
| 		FeatureFlags:             a.config.FeatureFlags, | ||||
| 		MaxFileSize:              a.config.MaxFileSize, | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import ( | ||||
|  | ||||
| 	"github.com/mattermost/focalboard/server/api" | ||||
| 	"github.com/mattermost/focalboard/server/model" | ||||
| 	mmModel "github.com/mattermost/mattermost-server/v6/model" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| @@ -823,6 +824,19 @@ func (c *Client) TeamUploadFile(teamID, boardID string, data io.Reader) (*api.Fi | ||||
| 	return fileUploadResponse, BuildResponse(r) | ||||
| } | ||||
|  | ||||
| func (c *Client) TeamUploadFileInfo(teamID, boardID string, fileName string) (*mmModel.FileInfo, *Response) { | ||||
| 	r, err := c.DoAPIGet(fmt.Sprintf("/files/teams/%s/%s/%s/info", teamID, boardID, fileName), "") | ||||
| 	if err != nil { | ||||
| 		return nil, BuildErrorResponse(r, err) | ||||
| 	} | ||||
| 	defer closeBody(r) | ||||
| 	fileInfoResponse, error := api.FileInfoResponseFromJSON(r.Body) | ||||
| 	if error != nil { | ||||
| 		return nil, BuildErrorResponse(r, error) | ||||
| 	} | ||||
| 	return fileInfoResponse, BuildResponse(r) | ||||
| } | ||||
|  | ||||
| func (c *Client) GetSubscriptionsRoute() string { | ||||
| 	return "/subscriptions" | ||||
| } | ||||
|   | ||||
| @@ -69,3 +69,20 @@ func TestUploadFile(t *testing.T) { | ||||
| 		require.NotNil(t, file.FileID) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestFileInfo(t *testing.T) { | ||||
| 	const ( | ||||
| 		testTeamID = "team-id" | ||||
| 	) | ||||
|  | ||||
| 	t.Run("Retrieving file info", func(t *testing.T) { | ||||
| 		th := SetupTestHelper(t).InitBasic() | ||||
| 		defer th.TearDown() | ||||
| 		testBoard := th.CreateBoard(testTeamID, model.BoardTypeOpen) | ||||
|  | ||||
| 		fileInfo, resp := th.Client.TeamUploadFileInfo(testTeamID, testBoard.ID, "test") | ||||
| 		th.CheckOK(resp) | ||||
| 		require.NotNil(t, fileInfo) | ||||
| 		require.NotNil(t, fileInfo.Id) | ||||
| 	}) | ||||
| } | ||||
|   | ||||
| @@ -22,4 +22,8 @@ type ClientConfig struct { | ||||
| 	// The server feature flags | ||||
| 	// required: true | ||||
| 	FeatureFlags map[string]string `json:"featureFlags"` | ||||
|  | ||||
| 	// Required for file upload to check the size of the file | ||||
| 	// required: true | ||||
| 	MaxFileSize int64 `json:"maxFileSize"` | ||||
| } | ||||
|   | ||||
| @@ -38,7 +38,7 @@ type Configuration struct { | ||||
| 	FilesDriver              string            `json:"filesdriver" mapstructure:"filesdriver"` | ||||
| 	FilesS3Config            AmazonS3Config    `json:"filess3config" mapstructure:"filess3config"` | ||||
| 	FilesPath                string            `json:"filespath" mapstructure:"filespath"` | ||||
| 	MaxFileSize              int64             `json:"maxfilesize" mapstructure:"mafilesize"` | ||||
| 	MaxFileSize              int64             `json:"maxfilesize" mapstructure:"maxfilesize"` | ||||
| 	Telemetry                bool              `json:"telemetry" mapstructure:"telemetry"` | ||||
| 	TelemetryID              string            `json:"telemetryid" mapstructure:"telemetryid"` | ||||
| 	PrometheusAddress        string            `json:"prometheusaddress" mapstructure:"prometheusaddress"` | ||||
|   | ||||
| @@ -1,5 +1,15 @@ | ||||
| { | ||||
|   "AppBar.Tooltip": "Toggle Linked Boards", | ||||
|   "Attachment.Attachment-title": "Attachment", | ||||
|   "AttachmentBlock.DeleteAction": "delete", | ||||
|   "AttachmentBlock.addElement": "add {type}", | ||||
|   "AttachmentBlock.delete": "Attachment Deleted Successfully.", | ||||
|   "AttachmentBlock.failed": "Unable to upload the file. Attachment size limit reached.", | ||||
|   "AttachmentBlock.upload": "Attachment uploading.", | ||||
|   "AttachmentBlock.uploadSuccess": "Attachment uploaded successfull.", | ||||
|   "AttachmentElement.delete-confirmation-dialog-button-text": "Delete", | ||||
|   "AttachmentElement.download": "Download", | ||||
|   "AttachmentElement.upload-percentage": "Uploading...({uploadPercent}%)", | ||||
|   "BoardComponent.add-a-group": "+ Add a group", | ||||
|   "BoardComponent.delete": "Delete", | ||||
|   "BoardComponent.hidden-columns": "Hidden columns", | ||||
| @@ -71,6 +81,7 @@ | ||||
|   "CardBadges.title-checkboxes": "Checkboxes", | ||||
|   "CardBadges.title-comments": "Comments", | ||||
|   "CardBadges.title-description": "This card has a description", | ||||
|   "CardDetail.Attach": "Attach", | ||||
|   "CardDetail.Follow": "Follow", | ||||
|   "CardDetail.Following": "Following", | ||||
|   "CardDetail.add-content": "Add content", | ||||
| @@ -92,6 +103,7 @@ | ||||
|   "CardDetailProperty.property-deleted": "Deleted {propertyName} successfully!", | ||||
|   "CardDetailProperty.property-name-change-subtext": "type from \"{oldPropType}\" to \"{newPropType}\"", | ||||
|   "CardDetial.limited-link": "Learn more about our plans.", | ||||
|   "CardDialog.delete-confirmation-dialog-attachment": "Confirm Attachment delete!", | ||||
|   "CardDialog.delete-confirmation-dialog-button-text": "Delete", | ||||
|   "CardDialog.delete-confirmation-dialog-heading": "Confirm card delete!", | ||||
|   "CardDialog.editing-template": "You're editing a template.", | ||||
| @@ -119,6 +131,7 @@ | ||||
|   "ContentBlock.editText": "Edit text...", | ||||
|   "ContentBlock.image": "image", | ||||
|   "ContentBlock.insertAbove": "Insert above", | ||||
|   "ContentBlock.moveBlock": "move card content", | ||||
|   "ContentBlock.moveDown": "Move down", | ||||
|   "ContentBlock.moveUp": "Move up", | ||||
|   "ContentBlock.text": "text", | ||||
|   | ||||
							
								
								
									
										28
									
								
								webapp/src/blocks/attachmentBlock.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								webapp/src/blocks/attachmentBlock.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
| import {Block, createBlock} from './block' | ||||
|  | ||||
| type AttachmentBlockFields = { | ||||
|     attachmentId: string | ||||
| } | ||||
|  | ||||
| type AttachmentBlock = Block & { | ||||
|     type: 'attachment' | ||||
|     fields: AttachmentBlockFields | ||||
|     isUploading: boolean | ||||
|     uploadingPercent: number | ||||
| } | ||||
|  | ||||
| function createAttachmentBlock(block?: Block): AttachmentBlock { | ||||
|     return { | ||||
|         ...createBlock(block), | ||||
|         type: 'attachment', | ||||
|         fields: { | ||||
|             attachmentId: block?.fields.attachmentId || '', | ||||
|         }, | ||||
|         isUploading: false, | ||||
|         uploadingPercent: 0, | ||||
|     } | ||||
| } | ||||
|  | ||||
| export {AttachmentBlock, createAttachmentBlock} | ||||
| @@ -8,7 +8,7 @@ import {Utils} from '../utils' | ||||
| 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 | ||||
| const blockTypes = [...contentBlockTypes, 'board', 'view', 'card', 'comment', 'attachment', 'unknown'] as const | ||||
| type ContentBlockTypes = typeof contentBlockTypes[number] | ||||
| type BlockTypes = typeof blockTypes[number] | ||||
|  | ||||
|   | ||||
| @@ -29,6 +29,16 @@ exports[`components/cardDialog already following card 1`] = ` | ||||
|             class="toolbar--right" | ||||
|           > | ||||
|             <div> | ||||
|               <button | ||||
|                 type="button" | ||||
|               > | ||||
|                 <i | ||||
|                   class="CompassIcon icon-paperclip" | ||||
|                 /> | ||||
|                 <span> | ||||
|                   Attach | ||||
|                 </span> | ||||
|               </button> | ||||
|               <button | ||||
|                 type="button" | ||||
|               > | ||||
| @@ -461,6 +471,16 @@ exports[`components/cardDialog return a cardDialog readonly 1`] = ` | ||||
|             class="toolbar--right" | ||||
|           > | ||||
|             <div> | ||||
|               <button | ||||
|                 type="button" | ||||
|               > | ||||
|                 <i | ||||
|                   class="CompassIcon icon-paperclip" | ||||
|                 /> | ||||
|                 <span> | ||||
|                   Attach | ||||
|                 </span> | ||||
|               </button> | ||||
|               <button | ||||
|                 type="button" | ||||
|               > | ||||
| @@ -580,6 +600,16 @@ exports[`components/cardDialog return cardDialog menu content 1`] = ` | ||||
|             class="toolbar--right" | ||||
|           > | ||||
|             <div> | ||||
|               <button | ||||
|                 type="button" | ||||
|               > | ||||
|                 <i | ||||
|                   class="CompassIcon icon-paperclip" | ||||
|                 /> | ||||
|                 <span> | ||||
|                   Attach | ||||
|                 </span> | ||||
|               </button> | ||||
|               <button | ||||
|                 type="button" | ||||
|               > | ||||
| @@ -917,6 +947,16 @@ exports[`components/cardDialog return cardDialog menu content and cancel delete | ||||
|             class="toolbar--right" | ||||
|           > | ||||
|             <div> | ||||
|               <button | ||||
|                 type="button" | ||||
|               > | ||||
|                 <i | ||||
|                   class="CompassIcon icon-paperclip" | ||||
|                 /> | ||||
|                 <span> | ||||
|                   Attach | ||||
|                 </span> | ||||
|               </button> | ||||
|               <button | ||||
|                 type="button" | ||||
|               > | ||||
| @@ -1117,6 +1157,16 @@ exports[`components/cardDialog should match snapshot 1`] = ` | ||||
|             class="toolbar--right" | ||||
|           > | ||||
|             <div> | ||||
|               <button | ||||
|                 type="button" | ||||
|               > | ||||
|                 <i | ||||
|                   class="CompassIcon icon-paperclip" | ||||
|                 /> | ||||
|                 <span> | ||||
|                   Attach | ||||
|                 </span> | ||||
|               </button> | ||||
|               <button | ||||
|                 type="button" | ||||
|               > | ||||
|   | ||||
							
								
								
									
										31
									
								
								webapp/src/components/cardDetail/attachment.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								webapp/src/components/cardDetail/attachment.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| .Attachment { | ||||
|     display: block; | ||||
|  | ||||
|     .attachment-header { | ||||
|         display: flex; | ||||
|     } | ||||
|  | ||||
|     .attachment-plus-btn { | ||||
|         margin-left: auto; | ||||
|     } | ||||
|  | ||||
|     .attachment-content { | ||||
|         padding-bottom: 20px; | ||||
|         display: flex; | ||||
|         overflow-x: auto; | ||||
|         width: 550px; | ||||
|     } | ||||
|  | ||||
|     .attachment-plus-icon { | ||||
|         color: rgba(var(--center-channel-color-rgb), 0.56); | ||||
|         cursor: pointer; | ||||
|     } | ||||
|  | ||||
|     .attachment-title { | ||||
|         font-family: 'Open Sans'; | ||||
|         font-style: normal; | ||||
|         font-weight: 600; | ||||
|         font-size: 14px; | ||||
|         line-height: 20px; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										58
									
								
								webapp/src/components/cardDetail/attachment.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								webapp/src/components/cardDetail/attachment.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
| import React from 'react' | ||||
|  | ||||
| import {useIntl} from 'react-intl' | ||||
|  | ||||
| import AttachmentElement from '../../components/content/attachmentElement' | ||||
| import {AttachmentBlock} from '../../blocks/attachmentBlock' | ||||
|  | ||||
| import './attachment.scss' | ||||
| import {Block} from '../../blocks/block' | ||||
| import CompassIcon from '../../widgets/icons/compassIcon' | ||||
| import BoardPermissionGate from '../../components/permissions/boardPermissionGate' | ||||
| import {Permission} from '../../constants' | ||||
|  | ||||
| type Props = { | ||||
|     attachments: AttachmentBlock[] | ||||
|     onDelete: (block: Block) => void | ||||
|     addAttachment: () => void | ||||
| } | ||||
|  | ||||
| const AttachmentList = (props: Props): JSX.Element => { | ||||
|     const {attachments, onDelete, addAttachment} = props | ||||
|     const intl = useIntl() | ||||
|  | ||||
|     return ( | ||||
|         <div className='Attachment'> | ||||
|             <div className='attachment-header'> | ||||
|                 <div className='attachment-title mb-2'>{intl.formatMessage({id: 'Attachment.Attachment-title', defaultMessage: 'Attachment'})} {`(${attachments.length})`}</div> | ||||
|                 <BoardPermissionGate permissions={[Permission.ManageBoardCards]}> | ||||
|                     <div | ||||
|                         className='attachment-plus-btn' | ||||
|                         onClick={addAttachment} | ||||
|                     > | ||||
|                         <CompassIcon | ||||
|                             icon='plus' | ||||
|                             className='attachment-plus-icon' | ||||
|                         /> | ||||
|                     </div> | ||||
|                 </BoardPermissionGate> | ||||
|             </div> | ||||
|             <div className='attachment-content'> | ||||
|                 {attachments.map((block: AttachmentBlock) => { | ||||
|                     return ( | ||||
|                         <div key={block.id}> | ||||
|                             <AttachmentElement | ||||
|                                 block={block} | ||||
|                                 onDelete={onDelete} | ||||
|                             /> | ||||
|                         </div>) | ||||
|                 }) | ||||
|                 } | ||||
|             </div> | ||||
|         </div> | ||||
|     ) | ||||
| } | ||||
|  | ||||
| export default AttachmentList | ||||
| @@ -108,8 +108,11 @@ describe('components/cardDetail/CardDetail', () => { | ||||
|                         card={card} | ||||
|                         comments={[comment1, comment2]} | ||||
|                         contents={[]} | ||||
|                         attachments={[]} | ||||
|                         readonly={false} | ||||
|                         onClose={jest.fn()} | ||||
|                         onDelete={jest.fn()} | ||||
|                         addAttachment={jest.fn()} | ||||
|                     />, | ||||
|                 )} | ||||
|             </ReduxProvider> | ||||
| @@ -171,8 +174,11 @@ describe('components/cardDetail/CardDetail', () => { | ||||
|                         card={card} | ||||
|                         comments={[comment1, comment2]} | ||||
|                         contents={[]} | ||||
|                         attachments={[]} | ||||
|                         readonly={true} | ||||
|                         onClose={jest.fn()} | ||||
|                         onDelete={jest.fn()} | ||||
|                         addAttachment={jest.fn()} | ||||
|                     />, | ||||
|                 )} | ||||
|             </ReduxProvider> | ||||
| @@ -262,8 +268,11 @@ describe('components/cardDetail/CardDetail', () => { | ||||
|                         card={onboardingCard} | ||||
|                         comments={[comment1, comment2]} | ||||
|                         contents={[]} | ||||
|                         attachments={[]} | ||||
|                         readonly={false} | ||||
|                         onClose={jest.fn()} | ||||
|                         onDelete={jest.fn()} | ||||
|                         addAttachment={jest.fn()} | ||||
|                     />, | ||||
|                 )} | ||||
|             </ReduxProvider> | ||||
| @@ -368,8 +377,11 @@ describe('components/cardDetail/CardDetail', () => { | ||||
|                         card={onboardingCard} | ||||
|                         comments={[comment1, comment2]} | ||||
|                         contents={[]} | ||||
|                         attachments={[]} | ||||
|                         readonly={false} | ||||
|                         onClose={jest.fn()} | ||||
|                         onDelete={jest.fn()} | ||||
|                         addAttachment={jest.fn()} | ||||
|                     />, | ||||
|                 )} | ||||
|             </ReduxProvider> | ||||
| @@ -478,8 +490,11 @@ describe('components/cardDetail/CardDetail', () => { | ||||
|                         card={onboardingCard} | ||||
|                         comments={[comment1, comment2]} | ||||
|                         contents={[text]} | ||||
|                         attachments={[]} | ||||
|                         readonly={false} | ||||
|                         onClose={jest.fn()} | ||||
|                         onDelete={jest.fn()} | ||||
|                         addAttachment={jest.fn()} | ||||
|                     />, | ||||
|                 )} | ||||
|             </ReduxProvider> | ||||
| @@ -563,8 +578,11 @@ describe('components/cardDetail/CardDetail', () => { | ||||
|                         card={limitedCard} | ||||
|                         comments={[comment1, comment2]} | ||||
|                         contents={[]} | ||||
|                         attachments={[]} | ||||
|                         readonly={false} | ||||
|                         onClose={jest.fn()} | ||||
|                         onDelete={jest.fn()} | ||||
|                         addAttachment={jest.fn()} | ||||
|                     />, | ||||
|                 )} | ||||
|             </ReduxProvider> | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import {Card} from '../../blocks/card' | ||||
| import {BoardView} from '../../blocks/boardView' | ||||
| import {Board} from '../../blocks/board' | ||||
| import {CommentBlock} from '../../blocks/commentBlock' | ||||
| import {AttachmentBlock} from '../../blocks/attachmentBlock' | ||||
| import {ContentBlock} from '../../blocks/contentBlock' | ||||
| import {Block, ContentBlockTypes, createBlock} from '../../blocks/block' | ||||
| import mutator from '../../mutator' | ||||
| @@ -39,6 +40,7 @@ import CardDetailContents from './cardDetailContents' | ||||
| import CardDetailContentsMenu from './cardDetailContentsMenu' | ||||
| import CardDetailProperties from './cardDetailProperties' | ||||
| import useImagePaste from './imagePaste' | ||||
| import AttachmentList from './attachment' | ||||
|  | ||||
| import './cardDetail.scss' | ||||
|  | ||||
| @@ -52,9 +54,12 @@ type Props = { | ||||
|     cards: Card[] | ||||
|     card: Card | ||||
|     comments: CommentBlock[] | ||||
|     attachments: AttachmentBlock[] | ||||
|     contents: Array<ContentBlock|ContentBlock[]> | ||||
|     readonly: boolean | ||||
|     onClose: () => void | ||||
|     onDelete: (block: Block) => void | ||||
|     addAttachment: () => void | ||||
| } | ||||
|  | ||||
| async function addBlockNewEditor(card: Card, intl: IntlShape, title: string, fields: any, contentType: ContentBlockTypes, afterBlockId: string, dispatch: any): Promise<Block> { | ||||
| @@ -94,7 +99,7 @@ async function addBlockNewEditor(card: Card, intl: IntlShape, title: string, fie | ||||
| } | ||||
|  | ||||
| const CardDetail = (props: Props): JSX.Element|null => { | ||||
|     const {card, comments} = props | ||||
|     const {card, comments, attachments, onDelete, addAttachment} = props | ||||
|     const {limited} = card | ||||
|     const [title, setTitle] = useState(card.title) | ||||
|     const [serverTitle, setServerTitle] = useState(card.title) | ||||
| @@ -285,6 +290,15 @@ const CardDetail = (props: Props): JSX.Element|null => { | ||||
|                     readonly={props.readonly} | ||||
|                 />} | ||||
|  | ||||
|                 {attachments.length !== 0 && <Fragment> | ||||
|                     <hr/> | ||||
|                     <AttachmentList | ||||
|                         attachments={attachments} | ||||
|                         onDelete={onDelete} | ||||
|                         addAttachment={addAttachment} | ||||
|                     /> | ||||
|                 </Fragment>} | ||||
|  | ||||
|                 {/* Comments */} | ||||
|  | ||||
|                 {!limited && <Fragment> | ||||
|   | ||||
| @@ -10,6 +10,14 @@ | ||||
| } | ||||
|  | ||||
| .cardFollowBtn { | ||||
|     display: inline-flex; | ||||
|  | ||||
|     &.attach { | ||||
|         margin-right: 20px; | ||||
|         color: rgba(var(--center-channel-color-rgb), 0.64); | ||||
|         background-color: rgba(var(--center-channel-color-rgb), 0.08); | ||||
|     } | ||||
|  | ||||
|     &.follow { | ||||
|         color: rgba(var(--center-channel-color-rgb), 0.64); | ||||
|         background-color: rgba(var(--center-channel-color-rgb), 0.08); | ||||
|   | ||||
| @@ -1,30 +1,36 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
| import React, {useState} from 'react' | ||||
| import React, {useState, useCallback} from 'react' | ||||
| import {FormattedMessage, useIntl} from 'react-intl' | ||||
|  | ||||
| import {Board} from '../blocks/board' | ||||
| import {BoardView} from '../blocks/boardView' | ||||
| import {Card} from '../blocks/card' | ||||
| import octoClient from '../octoClient' | ||||
| import mutator from '../mutator' | ||||
| import {getCard} from '../store/cards' | ||||
| import {getCardComments} from '../store/comments' | ||||
| import {getCardContents} from '../store/contents' | ||||
| import {useAppSelector} from '../store/hooks' | ||||
| import {useAppDispatch, useAppSelector} from '../store/hooks' | ||||
| import {getCardAttachments, updateAttachments, updateUploadPrecent} from '../store/attachments' | ||||
| import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../telemetry/telemetryClient' | ||||
| import {Utils} from '../utils' | ||||
| import CompassIcon from '../widgets/icons/compassIcon' | ||||
| import Menu from '../widgets/menu' | ||||
| import {sendFlashMessage} from '../components/flashMessages' | ||||
|  | ||||
| import ConfirmationDialogBox, {ConfirmationDialogBoxProps} from '../components/confirmationDialogBox' | ||||
|  | ||||
| import Button from '../widgets/buttons/button' | ||||
|  | ||||
| import {getUserBlockSubscriptionList} from '../store/initialLoad' | ||||
| import {getClientConfig} from '../store/clientConfig' | ||||
|  | ||||
| import {IUser} from '../user' | ||||
| import {getMe} from '../store/users' | ||||
| import {Permission} from '../constants' | ||||
| import {Block, createBlock} from '../blocks/block' | ||||
| import {AttachmentBlock, createAttachmentBlock} from '../blocks/attachmentBlock' | ||||
|  | ||||
| import BoardPermissionGate from './permissions/boardPermissionGate' | ||||
|  | ||||
| @@ -50,7 +56,10 @@ const CardDialog = (props: Props): JSX.Element => { | ||||
|     const card = useAppSelector(getCard(props.cardId)) | ||||
|     const contents = useAppSelector(getCardContents(props.cardId)) | ||||
|     const comments = useAppSelector(getCardComments(props.cardId)) | ||||
|     const attachments = useAppSelector(getCardAttachments(props.cardId)) | ||||
|     const clientConfig = useAppSelector(getClientConfig) | ||||
|     const intl = useIntl() | ||||
|     const dispatch = useAppDispatch() | ||||
|     const me = useAppSelector<IUser|null>(getMe) | ||||
|     const isTemplate = card && card.fields.isTemplate | ||||
|  | ||||
| @@ -114,43 +123,142 @@ const CardDialog = (props: Props): JSX.Element => { | ||||
|             onClickDelete={handleDeleteButtonOnClick} | ||||
|         > | ||||
|             {!isTemplate && | ||||
|                 <BoardPermissionGate permissions={[Permission.ManageBoardProperties]}> | ||||
|                     <Menu.Text | ||||
|                         id='makeTemplate' | ||||
|                         icon={ | ||||
|                             <CompassIcon | ||||
|                                 icon='plus' | ||||
|                             />} | ||||
|                         name='New template from card' | ||||
|                         onClick={makeTemplateClicked} | ||||
|                     /> | ||||
|                 </BoardPermissionGate> | ||||
|             <BoardPermissionGate permissions={[Permission.ManageBoardProperties]}> | ||||
|                 <Menu.Text | ||||
|                     id='makeTemplate' | ||||
|                     icon={ | ||||
|                         <CompassIcon | ||||
|                             icon='plus' | ||||
|                         />} | ||||
|                     name='New template from card' | ||||
|                     onClick={makeTemplateClicked} | ||||
|                 /> | ||||
|             </BoardPermissionGate> | ||||
|             } | ||||
|         </CardActionsMenu> | ||||
|     ) | ||||
|  | ||||
|     const removeUploadingAttachment = (uploadingBlock: Block) => { | ||||
|         uploadingBlock.deleteAt = 1 | ||||
|         const removeUploadingAttachmentBlock = createAttachmentBlock(uploadingBlock) | ||||
|         dispatch(updateAttachments([removeUploadingAttachmentBlock])) | ||||
|     } | ||||
|  | ||||
|     const selectAttachment = (boardId: string) => { | ||||
|         return new Promise<AttachmentBlock>( | ||||
|             (resolve) => { | ||||
|                 Utils.selectLocalFile(async (attachment) => { | ||||
|                     const uploadingBlock = createBlock() | ||||
|                     uploadingBlock.title = attachment.name | ||||
|                     uploadingBlock.fields.attachmentId = attachment.name | ||||
|                     uploadingBlock.boardId = boardId | ||||
|                     if (card) { | ||||
|                         uploadingBlock.parentId = card.id | ||||
|                     } | ||||
|                     const attachmentBlock = createAttachmentBlock(uploadingBlock) | ||||
|                     attachmentBlock.isUploading = true | ||||
|                     dispatch(updateAttachments([attachmentBlock])) | ||||
|                     if (attachment.size > clientConfig.maxFileSize) { | ||||
|                         removeUploadingAttachment(uploadingBlock) | ||||
|                         sendFlashMessage({content: intl.formatMessage({id: 'AttachmentBlock.failed', defaultMessage: 'Unable to upload the file. Attachment size limit reached.'}), severity: 'normal'}) | ||||
|                     } else { | ||||
|                         sendFlashMessage({content: intl.formatMessage({id: 'AttachmentBlock.upload', defaultMessage: 'Attachment uploading.'}), severity: 'normal'}) | ||||
|                         const xhr = await octoClient.uploadAttachment(boardId, attachment) | ||||
|                         if (xhr) { | ||||
|                             xhr.upload.onprogress = (event) => { | ||||
|                                 const percent = Math.floor((event.loaded / event.total) * 100) | ||||
|                                 dispatch(updateUploadPrecent({ | ||||
|                                     blockId: attachmentBlock.id, | ||||
|                                     uploadPercent: percent, | ||||
|                                 })) | ||||
|                             } | ||||
|  | ||||
|                             xhr.onload = () => { | ||||
|                                 if (xhr.status === 200 && xhr.readyState === 4) { | ||||
|                                     const json = JSON.parse(xhr.response) | ||||
|                                     const attachmentId = json.fileId | ||||
|                                     if (attachmentId) { | ||||
|                                         removeUploadingAttachment(uploadingBlock) | ||||
|                                         const block = createAttachmentBlock() | ||||
|                                         block.fields.attachmentId = attachmentId || '' | ||||
|                                         sendFlashMessage({content: intl.formatMessage({id: 'AttachmentBlock.uploadSuccess', defaultMessage: 'Attachment uploaded successfull.'}), severity: 'normal'}) | ||||
|                                         resolve(block) | ||||
|                                     } else { | ||||
|                                         removeUploadingAttachment(uploadingBlock) | ||||
|                                         sendFlashMessage({content: intl.formatMessage({id: 'AttachmentBlock.failed', defaultMessage: 'Unable to upload the file. Attachment size limit reached.'}), severity: 'normal'}) | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 }, | ||||
|                 '') | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     const addElement = async () => { | ||||
|         if (!card) { | ||||
|             return | ||||
|         } | ||||
|         const block = await selectAttachment(board.id) | ||||
|         block.parentId = card.id | ||||
|         block.boardId = card.boardId | ||||
|         const typeName = block.type | ||||
|         const description = intl.formatMessage({id: 'AttachmentBlock.addElement', defaultMessage: 'add {type}'}, {type: typeName}) | ||||
|         await mutator.insertBlock(block.boardId, block, description) | ||||
|     } | ||||
|  | ||||
|     const deleteBlock = useCallback(async (block: Block) => { | ||||
|         if (!card) { | ||||
|             return | ||||
|         } | ||||
|         const description = intl.formatMessage({id: 'AttachmentBlock.DeleteAction', defaultMessage: 'delete'}) | ||||
|         await mutator.deleteBlock(block, description) | ||||
|         sendFlashMessage({content: intl.formatMessage({id: 'AttachmentBlock.delete', defaultMessage: 'Attachment Deleted Successfully.'}), severity: 'normal'}) | ||||
|     }, [card?.boardId, card?.id, card?.fields.contentOrder]) | ||||
|  | ||||
|     const attachBtn = (): React.ReactNode => { | ||||
|         return ( | ||||
|             <BoardPermissionGate permissions={[Permission.ManageBoardCards]}> | ||||
|                 <Button | ||||
|                     icon={<CompassIcon icon='paperclip'/>} | ||||
|                     className='cardFollowBtn attach' | ||||
|                     size='medium' | ||||
|                     onClick={addElement} | ||||
|                 > | ||||
|                     {intl.formatMessage({id: 'CardDetail.Attach', defaultMessage: 'Attach'})} | ||||
|                 </Button> | ||||
|             </BoardPermissionGate> | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     const followActionButton = (following: boolean): React.ReactNode => { | ||||
|         const followBtn = ( | ||||
|             <Button | ||||
|                 className='cardFollowBtn follow' | ||||
|                 size='medium' | ||||
|                 onClick={() => mutator.followBlock(props.cardId, 'card', me!.id)} | ||||
|             > | ||||
|                 {intl.formatMessage({id: 'CardDetail.Follow', defaultMessage: 'Follow'})} | ||||
|             </Button> | ||||
|             <> | ||||
|                 <Button | ||||
|                     className='cardFollowBtn follow' | ||||
|                     size='medium' | ||||
|                     onClick={() => mutator.followBlock(props.cardId, 'card', me!.id)} | ||||
|                 > | ||||
|                     {intl.formatMessage({id: 'CardDetail.Follow', defaultMessage: 'Follow'})} | ||||
|                 </Button> | ||||
|             </> | ||||
|         ) | ||||
|  | ||||
|         const unfollowBtn = ( | ||||
|             <Button | ||||
|                 className='cardFollowBtn unfollow' | ||||
|                 size='medium' | ||||
|                 onClick={() => mutator.unfollowBlock(props.cardId, 'card', me!.id)} | ||||
|             > | ||||
|                 {intl.formatMessage({id: 'CardDetail.Following', defaultMessage: 'Following'})} | ||||
|             </Button> | ||||
|             <> | ||||
|                 <Button | ||||
|                     className='cardFollowBtn unfollow' | ||||
|                     size='medium' | ||||
|                     onClick={() => mutator.unfollowBlock(props.cardId, 'card', me!.id)} | ||||
|                 > | ||||
|                     {intl.formatMessage({id: 'CardDetail.Following', defaultMessage: 'Following'})} | ||||
|                 </Button> | ||||
|             </> | ||||
|         ) | ||||
|  | ||||
|         return following ? unfollowBtn : followBtn | ||||
|         return (<>{attachBtn()}{following ? unfollowBtn : followBtn}</>) | ||||
|     } | ||||
|  | ||||
|     const followingCards = useAppSelector(getUserBlockSubscriptionList) | ||||
| @@ -183,8 +291,11 @@ const CardDialog = (props: Props): JSX.Element => { | ||||
|                         card={card} | ||||
|                         contents={contents} | ||||
|                         comments={comments} | ||||
|                         attachments={attachments} | ||||
|                         readonly={props.readonly} | ||||
|                         onClose={props.onClose} | ||||
|                         onDelete={deleteBlock} | ||||
|                         addAttachment={addElement} | ||||
|                     />} | ||||
|  | ||||
|                 {!card && | ||||
|   | ||||
| @@ -0,0 +1,135 @@ | ||||
| // Jest Snapshot v1, https://goo.gl/fbAQLP | ||||
|  | ||||
| exports[`component/content/FileBlock archived file 1`] = ` | ||||
| <div> | ||||
|   <div | ||||
|     class="FileElement mr-4" | ||||
|   > | ||||
|     <div | ||||
|       class="fileElement-icon-division" | ||||
|     > | ||||
|       <i | ||||
|         class="CompassIcon icon-file-text-outline-large fileElement-icon" | ||||
|       /> | ||||
|     </div> | ||||
|     <div | ||||
|       class="fileElement-file-details mt-3" | ||||
|     > | ||||
|       <div | ||||
|         class="octo-tooltip tooltip-bottom" | ||||
|         data-tooltip="test.txt" | ||||
|       > | ||||
|         <div | ||||
|           class="fileElement-file-name" | ||||
|         > | ||||
|           test.txt | ||||
|         </div> | ||||
|       </div> | ||||
|       <div | ||||
|         class="fileElement-file-ext-and-size" | ||||
|       > | ||||
|         txt | ||||
|           | ||||
|         2.2 KiB | ||||
|       </div> | ||||
|     </div> | ||||
|     <div | ||||
|       class="fileElement-delete-download" | ||||
|     > | ||||
|       <div | ||||
|         aria-label="menuwrapper" | ||||
|         class="MenuWrapper mt-3 fileElement-menu-icon" | ||||
|         role="button" | ||||
|       > | ||||
|         <button | ||||
|           class="IconButton size--medium" | ||||
|           type="button" | ||||
|         > | ||||
|           <i | ||||
|             class="CompassIcon icon-dots-vertical" | ||||
|           /> | ||||
|         </button> | ||||
|       </div> | ||||
|       <div | ||||
|         class="octo-tooltip tooltip-bottom" | ||||
|         data-tooltip="Download" | ||||
|       > | ||||
|         <div | ||||
|           class="fileElement-download-btn mt-3 mr-2" | ||||
|         > | ||||
|           <i | ||||
|             class="CompassIcon icon-download-outline" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| `; | ||||
|  | ||||
| exports[`component/content/FileBlock should match snapshot 1`] = ` | ||||
| <div> | ||||
|   <div | ||||
|     class="FileElement mr-4" | ||||
|   > | ||||
|     <div | ||||
|       class="fileElement-icon-division" | ||||
|     > | ||||
|       <i | ||||
|         class="CompassIcon icon-file-text-outline-large fileElement-icon" | ||||
|       /> | ||||
|     </div> | ||||
|     <div | ||||
|       class="fileElement-file-details mt-3" | ||||
|     > | ||||
|       <div | ||||
|         class="octo-tooltip tooltip-bottom" | ||||
|         data-tooltip="test.txt" | ||||
|       > | ||||
|         <div | ||||
|           class="fileElement-file-name" | ||||
|         > | ||||
|           test.txt | ||||
|         </div> | ||||
|       </div> | ||||
|       <div | ||||
|         class="fileElement-file-ext-and-size" | ||||
|       > | ||||
|         txt | ||||
|           | ||||
|         2.2 KiB | ||||
|       </div> | ||||
|     </div> | ||||
|     <div | ||||
|       class="fileElement-delete-download" | ||||
|     > | ||||
|       <div | ||||
|         aria-label="menuwrapper" | ||||
|         class="MenuWrapper mt-3 fileElement-menu-icon" | ||||
|         role="button" | ||||
|       > | ||||
|         <button | ||||
|           class="IconButton size--medium" | ||||
|           type="button" | ||||
|         > | ||||
|           <i | ||||
|             class="CompassIcon icon-dots-vertical" | ||||
|           /> | ||||
|         </button> | ||||
|       </div> | ||||
|       <div | ||||
|         class="octo-tooltip tooltip-bottom" | ||||
|         data-tooltip="Download" | ||||
|       > | ||||
|         <div | ||||
|           class="fileElement-download-btn mt-3 mr-2" | ||||
|         > | ||||
|           <i | ||||
|             class="CompassIcon icon-download-outline" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| `; | ||||
							
								
								
									
										100
									
								
								webapp/src/components/content/attachmentElement.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								webapp/src/components/content/attachmentElement.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| .FileElement { | ||||
|     background: rgb(var(--center-channel-bg-rgb)); | ||||
|     border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); | ||||
|     min-width: 300px; | ||||
|     width: max-content; | ||||
|     height: 64px; | ||||
|     box-shadow: var(--elevation-1); | ||||
|     display: flex; | ||||
|     position: relative; | ||||
|  | ||||
|     .fileElement-file-name { | ||||
|         font-size: 14px; | ||||
|         font-weight: 600; | ||||
|     } | ||||
|  | ||||
|     .fileElement-file-ext-and-size { | ||||
|         text-transform: uppercase; | ||||
|         font-weight: 400; | ||||
|         font-size: 12px; | ||||
|         line-height: 16px; | ||||
|         color: rgb(var(--center-channel-color-rgb)); | ||||
|     } | ||||
|  | ||||
|     .fileElement-file-uploading { | ||||
|         font-weight: 400; | ||||
|         font-size: 12px; | ||||
|         line-height: 16px; | ||||
|         color: rgb(var(--center-channel-color-rgb)); | ||||
|     } | ||||
|  | ||||
|     .fileElement-icon-division { | ||||
|         margin-top: 8px; | ||||
|     } | ||||
|  | ||||
|     .fileElement-icon { | ||||
|         font-size: 48px; | ||||
|         color: rgba(237, 82, 42, 1); | ||||
|     } | ||||
|  | ||||
|     .fileElement-download-btn { | ||||
|         display: none; | ||||
|         font-size: 20px; | ||||
|         color: rgba(var(--center-channel-color-rgb), 0.56); | ||||
|         padding: 8px; | ||||
|  | ||||
|         &:hover { | ||||
|             background-color: rgba(var(--center-channel-color-rgb), 0.08); | ||||
|             border-radius: 5px; | ||||
|             cursor: pointer; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     .fileElement-menu-icon { | ||||
|         display: none; | ||||
|         float: right; | ||||
|     } | ||||
|  | ||||
|     .delete-menu { | ||||
|         margin-top: -30px; | ||||
|     } | ||||
|  | ||||
|     .fileElement-delete-download { | ||||
|         position: absolute; | ||||
|         display: flex; | ||||
|         right: 0; | ||||
|     } | ||||
|  | ||||
|     &:hover { | ||||
|         .fileElement-download-btn { | ||||
|             display: block; | ||||
|         } | ||||
|  | ||||
|         .fileElement-menu-icon { | ||||
|             display: block; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     .progress { | ||||
|         position: absolute; | ||||
|         bottom: 0; | ||||
|         width: 100%; | ||||
|         height: 7px; | ||||
|         margin-bottom: 0; | ||||
|         border-radius: 0; | ||||
|     } | ||||
|  | ||||
|     .progress-bar { | ||||
|         float: left; | ||||
|         width: 0%; | ||||
|         height: 100%; | ||||
|         line-height: 20px; | ||||
|         color: #fff; | ||||
|         text-align: center; | ||||
|         background-color: #285ab9; | ||||
|     } | ||||
|  | ||||
|     .dialog { | ||||
|         max-width: 550px !important; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										140
									
								
								webapp/src/components/content/attachmentElement.test.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								webapp/src/components/content/attachmentElement.test.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,140 @@ | ||||
| // 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} from '@testing-library/react' | ||||
| import {act} from 'react-dom/test-utils' | ||||
| import {mocked} from 'jest-mock' | ||||
|  | ||||
| import {AttachmentBlock} from '../../blocks/attachmentBlock' | ||||
| import {mockStateStore, wrapIntl} from '../../testUtils' | ||||
| import octoClient from '../../octoClient' | ||||
| import {TestBlockFactory} from '../../test/testBlockFactory' | ||||
| import {IUser} from '../../user' | ||||
|  | ||||
| import AttachmentElement from './attachmentElement' | ||||
|  | ||||
| jest.mock('../../octoClient') | ||||
| const mockedOcto = mocked(octoClient, true) | ||||
| mockedOcto.getFileAsDataUrl.mockResolvedValue({url: 'test.txt'}) | ||||
| mockedOcto.getFileInfo.mockResolvedValue({ | ||||
|     name: 'test.txt', | ||||
|     size: 2300, | ||||
|     extension: '.txt', | ||||
| }) | ||||
|  | ||||
| const board = TestBlockFactory.createBoard() | ||||
| board.id = '1' | ||||
| board.teamId = 'team-id' | ||||
| board.channelId = 'channel_1' | ||||
|  | ||||
| describe('component/content/FileBlock', () => { | ||||
|     const defaultBlock: AttachmentBlock = { | ||||
|         id: 'test-id', | ||||
|         boardId: '1', | ||||
|         parentId: '', | ||||
|         modifiedBy: 'test-user-id', | ||||
|         schema: 0, | ||||
|         type: 'attachment', | ||||
|         title: 'test-title', | ||||
|         fields: { | ||||
|             attachmentId: 'test.txt', | ||||
|         }, | ||||
|         createdBy: 'test-user-id', | ||||
|         createAt: 0, | ||||
|         updateAt: 0, | ||||
|         deleteAt: 0, | ||||
|         limited: false, | ||||
|         isUploading: false, | ||||
|         uploadingPercent: 0, | ||||
|     } | ||||
|  | ||||
|     const me: IUser = { | ||||
|         id: 'user-id-1', | ||||
|         username: 'username_1', | ||||
|         email: '', | ||||
|         nickname: '', | ||||
|         firstname: '', | ||||
|         lastname: '', | ||||
|         props: {}, | ||||
|         create_at: 0, | ||||
|         update_at: 0, | ||||
|         is_bot: false, | ||||
|         is_guest: false, | ||||
|         roles: 'system_user', | ||||
|     } | ||||
|  | ||||
|     const state = { | ||||
|         teams: { | ||||
|             current: {id: 'team-id', title: 'Test Team'}, | ||||
|         }, | ||||
|         users: { | ||||
|             me, | ||||
|             boardUsers: [me], | ||||
|             blockSubscriptions: [], | ||||
|         }, | ||||
|         boards: { | ||||
|             current: board.id, | ||||
|             boards: { | ||||
|                 [board.id]: board, | ||||
|             }, | ||||
|             templates: [], | ||||
|             membersInBoards: { | ||||
|                 [board.id]: {}, | ||||
|             }, | ||||
|             myBoardMemberships: { | ||||
|                 [board.id]: {userId: me.id, schemeAdmin: true}, | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         attachments: { | ||||
|             attachments: { | ||||
|                 'test-id': { | ||||
|                     uploadPercent: 0, | ||||
|                 }, | ||||
|             }, | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|     const store = mockStateStore([], state) | ||||
|  | ||||
|     test('should match snapshot', async () => { | ||||
|         const component = wrapIntl( | ||||
|             <ReduxProvider store={store}> | ||||
|                 <AttachmentElement | ||||
|                     block={defaultBlock} | ||||
|                 /> | ||||
|             </ReduxProvider>, | ||||
|         ) | ||||
|         let fileContainer: Element | undefined | ||||
|         await act(async () => { | ||||
|             const {container} = render(component) | ||||
|             fileContainer = container | ||||
|         }) | ||||
|         expect(fileContainer).toMatchSnapshot() | ||||
|     }) | ||||
|  | ||||
|     test('archived file', async () => { | ||||
|         mockedOcto.getFileAsDataUrl.mockResolvedValue({ | ||||
|             archived: true, | ||||
|             name: 'FileName', | ||||
|             extension: '.txt', | ||||
|             size: 165002, | ||||
|         }) | ||||
|  | ||||
|         const component = wrapIntl( | ||||
|             <ReduxProvider store={store}> | ||||
|                 <AttachmentElement | ||||
|                     block={defaultBlock} | ||||
|                 /> | ||||
|             </ReduxProvider>, | ||||
|         ) | ||||
|         let fileContainer: Element | undefined | ||||
|         await act(async () => { | ||||
|             const {container} = render(component) | ||||
|             fileContainer = container | ||||
|         }) | ||||
|         expect(fileContainer).toMatchSnapshot() | ||||
|     }) | ||||
| }) | ||||
							
								
								
									
										205
									
								
								webapp/src/components/content/attachmentElement.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										205
									
								
								webapp/src/components/content/attachmentElement.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,205 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
| import React, {useEffect, useState} from 'react' | ||||
| import {useIntl} from 'react-intl' | ||||
|  | ||||
| import octoClient from '../../octoClient' | ||||
|  | ||||
| import {AttachmentBlock} from '../../blocks/attachmentBlock' | ||||
| import {Block, FileInfo} from '../../blocks/block' | ||||
| import Files from '../../file' | ||||
| import FileIcons from '../../fileIcons' | ||||
|  | ||||
| import BoardPermissionGate from '../../components/permissions/boardPermissionGate' | ||||
| import ConfirmationDialogBox, {ConfirmationDialogBoxProps} from '../../components/confirmationDialogBox' | ||||
| import {Utils} from '../../utils' | ||||
| import {getUploadPercent} from '../../store/attachments' | ||||
| import {useAppSelector} from '../../store/hooks' | ||||
| import {Permission} from '../../constants' | ||||
|  | ||||
| import ArchivedFile from './archivedFile/archivedFile' | ||||
|  | ||||
| import './attachmentElement.scss' | ||||
| import CompassIcon from './../../widgets/icons/compassIcon' | ||||
| import MenuWrapper from './../../widgets/menuWrapper' | ||||
| import IconButton from './../../widgets/buttons/iconButton' | ||||
| import Menu from './../../widgets/menu' | ||||
| import Tooltip from './../../widgets/tooltip' | ||||
|  | ||||
| type Props = { | ||||
|     block: AttachmentBlock | ||||
|     onDelete?: (block: Block) => void | ||||
| } | ||||
|  | ||||
| const AttachmentElement = (props: Props): JSX.Element|null => { | ||||
|     const {block, onDelete} = props | ||||
|     const [fileInfo, setFileInfo] = useState<FileInfo>({}) | ||||
|     const [fileSize, setFileSize] = useState<string>() | ||||
|     const [fileIcon, setFileIcon] = useState<string>('file-text-outline-larg') | ||||
|     const [fileName, setFileName] = useState<string>() | ||||
|     const [showConfirmationDialogBox, setShowConfirmationDialogBox] = useState<boolean>(false) | ||||
|     const uploadPercent = useAppSelector(getUploadPercent(block.id)) | ||||
|     const intl = useIntl() | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const loadFile = async () => { | ||||
|             if (block.isUploading) { | ||||
|                 setFileInfo({ | ||||
|                     name: block.title, | ||||
|                     extension: block.title.split('.').slice(0, -1).join('.'), | ||||
|                 }) | ||||
|                 return | ||||
|             } | ||||
|             const attachmentInfo = await octoClient.getFileInfo(block.boardId, block.fields.attachmentId) | ||||
|             setFileInfo(attachmentInfo) | ||||
|         } | ||||
|         loadFile() | ||||
|     }, []) | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (fileInfo.size && !fileSize) { | ||||
|             setFileSize(Utils.humanFileSize(fileInfo.size)) | ||||
|         } | ||||
|         if (fileInfo.name && !fileName) { | ||||
|             const generateFileName = (fName: string) => { | ||||
|                 if (fName.length > 21) { | ||||
|                     let result = fName.slice(0, 18) | ||||
|                     result += '...' | ||||
|                     return result | ||||
|                 } | ||||
|                 return fName | ||||
|             } | ||||
|             setFileName(generateFileName(fileInfo.name)) | ||||
|         } | ||||
|     }, [fileInfo.size, fileInfo.name]) | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (fileInfo.extension) { | ||||
|             const getFileIcon = (fileExt: string) => { | ||||
|                 const extType = (Object.keys(Files) as string[]).find((key) => Files[key].find((ext) => ext === fileExt)) | ||||
|                 if (extType) { | ||||
|                     setFileIcon(FileIcons[extType]) | ||||
|                 } else { | ||||
|                     setFileIcon('file-generic-outline-large') | ||||
|                 } | ||||
|             } | ||||
|             getFileIcon(fileInfo.extension.substring(1)) | ||||
|         } | ||||
|     }, [fileInfo.extension]) | ||||
|  | ||||
|     const deleteAttachment = () => { | ||||
|         if (onDelete) { | ||||
|             onDelete(block) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     const confirmDialogProps: ConfirmationDialogBoxProps = { | ||||
|         heading: intl.formatMessage({id: 'CardDialog.delete-confirmation-dialog-attachment', defaultMessage: 'Confirm Attachment delete!'}), | ||||
|         confirmButtonText: intl.formatMessage({id: 'AttachmentElement.delete-confirmation-dialog-button-text', defaultMessage: 'Delete'}), | ||||
|         onConfirm: deleteAttachment, | ||||
|         onClose: () => { | ||||
|             setShowConfirmationDialogBox(false) | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|     const handleDeleteButtonClick = () => { | ||||
|         setShowConfirmationDialogBox(true) | ||||
|     } | ||||
|  | ||||
|     if (fileInfo.archived) { | ||||
|         return ( | ||||
|             <ArchivedFile fileInfo={fileInfo}/> | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     const attachmentDownloadHandler = async () => { | ||||
|         const attachment = await octoClient.getFileAsDataUrl(block.boardId, block.fields.attachmentId) | ||||
|         const anchor = document.createElement('a') | ||||
|         anchor.href = attachment.url || '' | ||||
|         anchor.download = fileInfo.name || '' | ||||
|         document.body.appendChild(anchor) | ||||
|         anchor.click() | ||||
|         document.body.removeChild(anchor) | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <div className='FileElement mr-4'> | ||||
|             {showConfirmationDialogBox && <ConfirmationDialogBox dialogBox={confirmDialogProps}/>} | ||||
|             <div className='fileElement-icon-division'> | ||||
|                 <CompassIcon | ||||
|                     icon={fileIcon} | ||||
|                     className='fileElement-icon' | ||||
|                 /> | ||||
|             </div> | ||||
|             <div className='fileElement-file-details mt-3'> | ||||
|                 <Tooltip | ||||
|                     title={fileInfo.name ? fileInfo.name : ''} | ||||
|                     placement='bottom' | ||||
|                 > | ||||
|                     <div className='fileElement-file-name'> | ||||
|                         {fileName} | ||||
|                     </div> | ||||
|                 </Tooltip> | ||||
|                 {!block.isUploading && <div className='fileElement-file-ext-and-size'> | ||||
|                     {fileInfo.extension?.substring(1)} {fileSize} | ||||
|                 </div> } | ||||
|                 {block.isUploading && <div className='fileElement-file-uploading'> | ||||
|                     {intl.formatMessage({ | ||||
|                         id: 'AttachmentElement.upload-percentage', | ||||
|                         defaultMessage: 'Uploading...({uploadPercent}%)', | ||||
|                     }, { | ||||
|                         uploadPercent, | ||||
|                     })} | ||||
|                 </div>} | ||||
|             </div> | ||||
|             {block.isUploading && | ||||
|                 <div className='progress'> | ||||
|                     <span | ||||
|                         className='progress-bar' | ||||
|                         style={{width: uploadPercent + '%'}} | ||||
|                     > | ||||
|                         {''} | ||||
|                     </span> | ||||
|                 </div>} | ||||
|             {!block.isUploading && | ||||
|             <div className='fileElement-delete-download'> | ||||
|                 <BoardPermissionGate permissions={[Permission.ManageBoardCards]}> | ||||
|                     <MenuWrapper className='mt-3 fileElement-menu-icon'> | ||||
|                         <IconButton | ||||
|                             size='medium' | ||||
|                             icon={<CompassIcon icon='dots-vertical'/>} | ||||
|                         /> | ||||
|                         <div className='delete-menu'> | ||||
|                             <Menu position='left'> | ||||
|                                 <Menu.Text | ||||
|                                     id='makeTemplate' | ||||
|                                     icon={ | ||||
|                                         <CompassIcon | ||||
|                                             icon='trash-can-outline' | ||||
|                                         />} | ||||
|                                     name='Delete' | ||||
|                                     onClick={handleDeleteButtonClick} | ||||
|                                 /> | ||||
|                             </Menu> | ||||
|                         </div> | ||||
|                     </MenuWrapper> | ||||
|                 </BoardPermissionGate> | ||||
|                 <Tooltip | ||||
|                     title={intl.formatMessage({id: 'AttachmentElement.download', defaultMessage: 'Download'})} | ||||
|                     placement='bottom' | ||||
|                 > | ||||
|                     <div | ||||
|                         className='fileElement-download-btn mt-3 mr-2' | ||||
|                         onClick={attachmentDownloadHandler} | ||||
|                     > | ||||
|                         <CompassIcon | ||||
|                             icon='download-outline' | ||||
|                         /> | ||||
|                     </div> | ||||
|                 </Tooltip> | ||||
|             </div> } | ||||
|         </div> | ||||
|     ) | ||||
| } | ||||
|  | ||||
| export default React.memo(AttachmentElement) | ||||
| @@ -7,4 +7,5 @@ export type ClientConfig = { | ||||
|     enablePublicSharedBoards: boolean | ||||
|     featureFlags: Record<string, string> | ||||
|     teammateNameDisplay: string | ||||
|     maxFileSize: number | ||||
| } | ||||
|   | ||||
							
								
								
									
										18
									
								
								webapp/src/file.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								webapp/src/file.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| const Files: Record<string, string[]> = { | ||||
|     AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg'], | ||||
|     CODE_TYPES: ['as', 'applescript', 'osascript', 'scpt', 'bash', 'sh', 'zsh', 'clj', 'boot', 'cl2', 'cljc', 'cljs', 'cljs.hl', 'cljscm', 'cljx', 'hic', 'coffee', '_coffee', 'cake', 'cjsx', 'cson', 'iced', 'cpp', 'c', 'cc', 'h', 'c++', 'h++', 'hpp', 'cs', 'csharp', 'css', 'd', 'di', 'dart', 'delphi', 'dpr', 'dfm', 'pas', 'pascal', 'freepascal', 'lazarus', 'lpr', 'lfm', 'diff', 'django', 'jinja', 'dockerfile', 'docker', 'erl', 'f90', 'f95', 'fsharp', 'fs', 'gcode', 'nc', 'go', 'groovy', 'handlebars', 'hbs', 'html.hbs', 'html.handlebars', 'hs', 'hx', 'java', 'jsp', 'js', 'jsx', 'json', 'jl', 'kt', 'ktm', 'kts', 'less', 'lisp', 'lua', 'mk', 'mak', 'md', 'mkdown', 'mkd', 'matlab', 'm', 'mm', 'objc', 'obj-c', 'ml', 'perl', 'pl', 'php', 'php3', 'php4', 'php5', 'php6', 'ps', 'ps1', 'pp', 'py', 'gyp', 'r', 'ruby', 'rb', 'gemspec', 'podspec', 'thor', 'irb', 'rs', 'scala', 'scm', 'sld', 'scss', 'st', 'sql', 'swift', 'tex', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml', 'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'], | ||||
|     IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg', 'tiff', 'tif'], | ||||
|     PATCH_TYPES: ['patch'], | ||||
|     PDF_TYPES: ['pdf'], | ||||
|     PRESENTATION_TYPES: ['ppt', 'pptx'], | ||||
|     SPREADSHEET_TYPES: ['xlsx', 'csv'], | ||||
|     TEXT_TYPES: ['txt', 'rtf'], | ||||
|     VIDEO_TYPES: ['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv'], | ||||
|     WORD_TYPES: ['doc', 'docx'], | ||||
|     COMPRESSED_TYPES: ['arc', 'arj', 'b64', 'btoa', 'bz', 'bz2', 'cab', 'cpt', 'gz', 'hqx', 'iso', 'lha', 'lzh', 'mim', 'mme', 'pak', 'pf', 'rar', 'rpm', 'sea', 'sit', 'sitx', 'tar', 'gz', 'tbz', 'tbz2', 'tgz', 'uu', 'uue', 'z', 'zip', 'zipx', 'zoo'], | ||||
| } | ||||
|  | ||||
| export default Files | ||||
							
								
								
									
										18
									
								
								webapp/src/fileIcons.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								webapp/src/fileIcons.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| const FileIcons: Record<string, string> = { | ||||
|     AUDIO_TYPES: 'file-audio-outline', | ||||
|     CODE_TYPES: 'file-code-outline-large', | ||||
|     IMAGE_TYPES: 'file-image-outline-large', | ||||
|     PDF_TYPES: 'file-pdf-outline-large', | ||||
|     PATCH_TYPES: 'file-patch-outline-large', | ||||
|     PRESENTATION_TYPES: 'file-powerpoint-outline-large', | ||||
|     SPREADSHEET_TYPES: 'file-excel-outline-large', | ||||
|     TEXT_TYPES: 'file-text-outline-large', | ||||
|     VIDEO_TYPES: 'file-video-outline-large', | ||||
|     WORD_TYPES: 'file-word-outline-large', | ||||
|     COMPRESSED_TYPES: 'file-zip-outline-large', | ||||
| } | ||||
|  | ||||
| export default FileIcons | ||||
| @@ -79,3 +79,22 @@ function createBlocks(): Block[] { | ||||
|  | ||||
|     return blocks | ||||
| } | ||||
|  | ||||
| test('OctoClient: GetFileInfo', async () => { | ||||
|     FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify({ | ||||
|         name: 'test.txt', | ||||
|         size: 2300, | ||||
|         extension: '.txt', | ||||
|     }))) | ||||
|     await octoClient.getFileInfo('board-id', 'file-id') | ||||
|     expect(FetchMock.fn).toBeCalledTimes(1) | ||||
|     expect(FetchMock.fn).toHaveBeenCalledWith( | ||||
|         'http://localhost/api/v2/files/teams/0/board-id/file-id/info', | ||||
|         expect.objectContaining({ | ||||
|             headers: { | ||||
|                 Accept: 'application/json', | ||||
|                 Authorization: '', | ||||
|                 'Content-Type': 'application/json', | ||||
|                 'X-Requested-With': 'XMLHttpRequest', | ||||
|             }})) | ||||
| }) | ||||
|   | ||||
| @@ -590,6 +590,45 @@ class OctoClient { | ||||
|         return undefined | ||||
|     } | ||||
|  | ||||
|     async uploadAttachment(rootID: string, file: File): Promise<XMLHttpRequest | undefined> { | ||||
|         const formData = new FormData() | ||||
|         formData.append('file', file) | ||||
|  | ||||
|         const xhr = new XMLHttpRequest() | ||||
|  | ||||
|         xhr.open('POST', this.getBaseURL() + this.teamPath() + '/' + rootID + '/files', true) | ||||
|         const headers = this.headers() as Record<string, string> | ||||
|         delete headers['Content-Type'] | ||||
|  | ||||
|         xhr.setRequestHeader('Accept', 'application/json') | ||||
|         xhr.setRequestHeader('Authorization', this.token ? 'Bearer ' + this.token : '') | ||||
|         xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest') | ||||
|  | ||||
|         if (xhr.upload) { | ||||
|             xhr.upload.onprogress = () => {} | ||||
|         } | ||||
|         xhr.send(formData) | ||||
|         return xhr | ||||
|     } | ||||
|  | ||||
|     async getFileInfo(boardId: string, fileId: string): Promise<FileInfo> { | ||||
|         let path = '/api/v2/files/teams/' + this.teamId + '/' + boardId + '/' + fileId + '/info' | ||||
|         const readToken = Utils.getReadToken() | ||||
|         if (readToken) { | ||||
|             path += `?read_token=${readToken}` | ||||
|         } | ||||
|         const response = await fetch(this.getBaseURL() + path, {headers: this.headers()}) | ||||
|         let fileInfo: FileInfo = {} | ||||
|  | ||||
|         if (response.status === 200) { | ||||
|             fileInfo = this.getJson(response, {}) as FileInfo | ||||
|         } else if (response.status === 400) { | ||||
|             fileInfo = await this.getJson(response, {}) as FileInfo | ||||
|         } | ||||
|  | ||||
|         return fileInfo | ||||
|     } | ||||
|  | ||||
|     async getFileAsDataUrl(boardId: string, fileId: string): Promise<FileInfo> { | ||||
|         let path = '/api/v2/files/teams/' + this.teamId + '/' + boardId + '/' + fileId | ||||
|         const readToken = Utils.getReadToken() | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import {createH1Block} from './blocks/h1Block' | ||||
| import {createH2Block} from './blocks/h2Block' | ||||
| import {createH3Block} from './blocks/h3Block' | ||||
| import {FilterCondition} from './blocks/filterClause' | ||||
| import {createAttachmentBlock} from './blocks/attachmentBlock' | ||||
| import {Utils} from './utils' | ||||
|  | ||||
| class OctoUtils { | ||||
| @@ -30,6 +31,7 @@ class OctoUtils { | ||||
|         case 'divider': { return createDividerBlock(block) } | ||||
|         case 'comment': { return createCommentBlock(block) } | ||||
|         case 'checkbox': { return createCheckboxBlock(block) } | ||||
|         case 'attachment': { return createAttachmentBlock(block) } | ||||
|         default: { | ||||
|             Utils.assertFailure(`Can't hydrate unknown block type: ${block.type}`) | ||||
|             return createBlock(block) | ||||
|   | ||||
| @@ -16,6 +16,7 @@ import {IUser, UserConfigPatch} from '../../user' | ||||
| import {Block} from '../../blocks/block' | ||||
| import {ContentBlock} from '../../blocks/contentBlock' | ||||
| import {CommentBlock} from '../../blocks/commentBlock' | ||||
| import {AttachmentBlock} from '../../blocks/attachmentBlock' | ||||
| import {Board, BoardMember} from '../../blocks/board' | ||||
| import {BoardView} from '../../blocks/boardView' | ||||
| import {Card} from '../../blocks/card' | ||||
| @@ -33,6 +34,7 @@ import {useAppSelector, useAppDispatch} from '../../store/hooks' | ||||
| import {setTeam} from '../../store/teams' | ||||
| import {updateCards} from '../../store/cards' | ||||
| import {updateComments} from '../../store/comments' | ||||
| import {updateAttachments} from '../../store/attachments' | ||||
| import {updateContents} from '../../store/contents' | ||||
| import { | ||||
|     fetchUserBlockSubscriptions, | ||||
| @@ -114,7 +116,8 @@ const BoardPage = (props: Props): JSX.Element => { | ||||
|                 dispatch(updateViews(teamBlocks.filter((b: Block) => b.type === 'view' || b.deleteAt !== 0) as BoardView[])) | ||||
|                 dispatch(updateCards(teamBlocks.filter((b: Block) => b.type === 'card' || b.deleteAt !== 0) as Card[])) | ||||
|                 dispatch(updateComments(teamBlocks.filter((b: Block) => b.type === 'comment' || b.deleteAt !== 0) as CommentBlock[])) | ||||
|                 dispatch(updateContents(teamBlocks.filter((b: Block) => b.type !== 'card' && b.type !== 'view' && b.type !== 'board' && b.type !== 'comment') as ContentBlock[])) | ||||
|                 dispatch(updateAttachments(teamBlocks.filter((b: Block) => b.type === 'attachment' || b.deleteAt !== 0) as AttachmentBlock[])) | ||||
|                 dispatch(updateContents(teamBlocks.filter((b: Block) => b.type !== 'card' && b.type !== 'view' && b.type !== 'board' && b.type !== 'comment' && b.type !== 'attachment') as ContentBlock[])) | ||||
|             }) | ||||
|         } | ||||
|  | ||||
|   | ||||
							
								
								
									
										90
									
								
								webapp/src/store/attachments.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								webapp/src/store/attachments.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,90 @@ | ||||
| // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. | ||||
| // See LICENSE.txt for license information. | ||||
|  | ||||
| import {createSlice, PayloadAction} from '@reduxjs/toolkit' | ||||
|  | ||||
| import {AttachmentBlock} from '../blocks/attachmentBlock' | ||||
|  | ||||
| import {loadBoardData, initialReadOnlyLoad} from './initialLoad' | ||||
|  | ||||
| import {RootState} from './index' | ||||
|  | ||||
| type AttachmentsState = { | ||||
|     attachments: {[key: string]: AttachmentBlock} | ||||
|     attachmentsByCard: {[key: string]: AttachmentBlock[]} | ||||
| } | ||||
|  | ||||
| const attachmentSlice = createSlice({ | ||||
|     name: 'attachments', | ||||
|     initialState: {attachments: {}, attachmentsByCard: {}} as AttachmentsState, | ||||
|     reducers: { | ||||
|         updateAttachments: (state, action: PayloadAction<AttachmentBlock[]>) => { | ||||
|             for (const attachment of action.payload) { | ||||
|                 if (attachment.deleteAt === 0) { | ||||
|                     state.attachments[attachment.id] = attachment | ||||
|                     if (!state.attachmentsByCard[attachment.parentId]) { | ||||
|                         state.attachmentsByCard[attachment.parentId] = [attachment] | ||||
|                         return | ||||
|                     } | ||||
|                     state.attachmentsByCard[attachment.parentId].push(attachment) | ||||
|                 } else { | ||||
|                     const parentId = state.attachments[attachment.id]?.parentId | ||||
|                     if (!state.attachmentsByCard[parentId]) { | ||||
|                         delete state.attachments[attachment.id] | ||||
|                         return | ||||
|                     } | ||||
|                     for (let i = 0; i < state.attachmentsByCard[parentId].length; i++) { | ||||
|                         if (state.attachmentsByCard[parentId][i].id === attachment.id) { | ||||
|                             state.attachmentsByCard[parentId].splice(i, 1) | ||||
|                         } | ||||
|                     } | ||||
|                     delete state.attachments[attachment.id] | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         updateUploadPrecent: (state, action: PayloadAction<{blockId: string, uploadPercent: number}>) => { | ||||
|             state.attachments[action.payload.blockId].uploadingPercent = action.payload.uploadPercent | ||||
|         }, | ||||
|     }, | ||||
|     extraReducers: (builder) => { | ||||
|         builder.addCase(initialReadOnlyLoad.fulfilled, (state, action) => { | ||||
|             state.attachments = {} | ||||
|             state.attachmentsByCard = {} | ||||
|             for (const block of action.payload.blocks) { | ||||
|                 if (block.type === 'attachment') { | ||||
|                     state.attachments[block.id] = block as AttachmentBlock | ||||
|                     state.attachmentsByCard[block.parentId] = state.attachmentsByCard[block.parentId] || [] | ||||
|                     state.attachmentsByCard[block.parentId].push(block as AttachmentBlock) | ||||
|                 } | ||||
|             } | ||||
|             Object.values(state.attachmentsByCard).forEach((arr) => arr.sort((a, b) => a.createAt - b.createAt)) | ||||
|         }) | ||||
|         builder.addCase(loadBoardData.fulfilled, (state, action) => { | ||||
|             state.attachments = {} | ||||
|             state.attachmentsByCard = {} | ||||
|             for (const block of action.payload.blocks) { | ||||
|                 if (block.type === 'attachment') { | ||||
|                     state.attachments[block.id] = block as AttachmentBlock | ||||
|                     state.attachmentsByCard[block.parentId] = state.attachmentsByCard[block.parentId] || [] | ||||
|                     state.attachmentsByCard[block.parentId].push(block as AttachmentBlock) | ||||
|                 } | ||||
|             } | ||||
|             Object.values(state.attachmentsByCard).forEach((arr) => arr.sort((a, b) => a.createAt - b.createAt)) | ||||
|         }) | ||||
|     }, | ||||
| }) | ||||
|  | ||||
| export const {updateAttachments, updateUploadPrecent} = attachmentSlice.actions | ||||
| export const {reducer} = attachmentSlice | ||||
|  | ||||
| export function getCardAttachments(cardId: string): (state: RootState) => AttachmentBlock[] { | ||||
|     return (state: RootState): AttachmentBlock[] => { | ||||
|         return (state.attachments?.attachmentsByCard && state.attachments.attachmentsByCard[cardId]) || [] | ||||
|     } | ||||
| } | ||||
|  | ||||
| export function getUploadPercent(blockId: string): (state: RootState) => number { | ||||
|     return (state: RootState): number => { | ||||
|         return (state.attachments.attachments[blockId].uploadingPercent) | ||||
|     } | ||||
| } | ||||
| @@ -18,7 +18,7 @@ export const fetchClientConfig = createAsyncThunk( | ||||
|  | ||||
| const clientConfigSlice = createSlice({ | ||||
|     name: 'config', | ||||
|     initialState: {value: {telemetry: false, telemetryid: '', enablePublicSharedBoards: false, teammateNameDisplay: ShowUsername, featureFlags: {}}} as {value: ClientConfig}, | ||||
|     initialState: {value: {telemetry: false, telemetryid: '', enablePublicSharedBoards: false, teammateNameDisplay: ShowUsername, featureFlags: {}, maxFileSize: 0}} as {value: ClientConfig}, | ||||
|     reducers: { | ||||
|         setClientConfig: (state, action: PayloadAction<ClientConfig>) => { | ||||
|             state.value = action.payload | ||||
| @@ -26,7 +26,7 @@ const clientConfigSlice = createSlice({ | ||||
|     }, | ||||
|     extraReducers: (builder) => { | ||||
|         builder.addCase(fetchClientConfig.fulfilled, (state, action) => { | ||||
|             state.value = action.payload || {telemetry: false, telemetryid: '', enablePublicSharedBoards: false, teammateNameDisplay: ShowUsername, featureFlags: {}} | ||||
|             state.value = action.payload || {telemetry: false, telemetryid: '', enablePublicSharedBoards: false, teammateNameDisplay: ShowUsername, featureFlags: {}, maxFileSize: 0} | ||||
|         }) | ||||
|     }, | ||||
| }) | ||||
|   | ||||
| @@ -18,6 +18,7 @@ import {reducer as globalErrorReducer} from './globalError' | ||||
| import {reducer as clientConfigReducer} from './clientConfig' | ||||
| import {reducer as sidebarReducer} from './sidebar' | ||||
| import {reducer as limitsReducer} from './limits' | ||||
| import {reducer as attachmentsReducer} from './attachments' | ||||
|  | ||||
| const store = configureStore({ | ||||
|     reducer: { | ||||
| @@ -36,6 +37,7 @@ const store = configureStore({ | ||||
|         clientConfig: clientConfigReducer, | ||||
|         sidebar: sidebarReducer, | ||||
|         limits: limitsReducer, | ||||
|         attachments: attachmentsReducer, | ||||
|     }, | ||||
| }) | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user