File attachment in the card (#4053)

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
This commit is contained in:
Rajat Dabade 2022-11-24 17:16:59 +05:30 committed by GitHub
parent 9918a0b3f8
commit 6674402079
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1264 additions and 48 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

@ -7,4 +7,5 @@ export type ClientConfig = {
enablePublicSharedBoards: boolean
featureFlags: Record<string, string>
teammateNameDisplay: string
maxFileSize: number
}

18
webapp/src/file.ts Normal file
View 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
View 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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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