Merge branch 'main' into only-explicit-boards-on-default-category
This commit is contained in:
commit
6a8d2455b5
35 changed files with 1274 additions and 58 deletions
|
@ -16,17 +16,17 @@ Like what you see? :eyes: Give us a GitHub Star! :star:
|
|||
|
||||
It helps define, organize, track and manage work across individuals and teams. Focalboard comes in two main editions:
|
||||
|
||||
* **[Mattermost Boards](https://www.focalboard.com/download/mattermost/)**: A self-hosted or **[free cloud server](https://mattermost.com/sign-up/?utm_source=focalboard&utm_campaign=focalboard)** for your team to plan and collaborate.
|
||||
* **[Mattermost Boards](https://www.focalboard.com/download/mattermost/)**: A self-hosted or **[free cloud server](https://mattermost.com/sign-up/?utm_source=github&utm_campaign=focalboard)** for your team to plan and collaborate.
|
||||
|
||||
* **[Personal Desktop](https://www.focalboard.com/download/personal-edition/desktop/)**: A standalone, single-user [Mac](https://apps.apple.com/app/apple-store/id1556908618?pt=2114704&ct=website&mt=8), [Windows](https://www.microsoft.com/store/apps/9NLN2T0SX9VF?cid=website), or [Linux](https://www.focalboard.com/download/personal-edition/desktop/#linux-desktop) desktop app for your own todos and personal projects.
|
||||
* **[Personal Desktop](https://www.focalboard.com/download/personal-edition/desktop/)**: A standalone, single-user [macOS](https://apps.apple.com/app/apple-store/id1556908618?pt=2114704&ct=website&mt=8), [Windows](https://www.microsoft.com/store/apps/9NLN2T0SX9VF?cid=website), or [Linux](https://www.focalboard.com/download/personal-edition/desktop/#linux-desktop) desktop app for your own todos and personal projects.
|
||||
|
||||
Focalboard can also be installed as a standalone **[Personal Server](https://www.focalboard.com/download/personal-edition/ubuntu/)** for development and personal use.
|
||||
|
||||
## Try Focalboard
|
||||
|
||||
### Mattermost Boards - [now available as a free cloud server](https://mattermost.com/sign-up/?utm_source=focalboard&utm_campaign=focalboard)
|
||||
### Mattermost Boards - [now available as a free cloud server](https://mattermost.com/sign-up/?utm_source=github&utm_campaign=focalboard)
|
||||
|
||||
**Mattermost Boards** combines project management tools with messaging and collaboration for teams of all sizes. To access and use **Mattermost Boards**, install or upgrade to Mattermost v6.0 or later as a [self-hosted server](https://docs.mattermost.com/guides/deployment.html?utm_source=focalboard&utm_campaign=focalboard) or [Cloud server](https://mattermost.com/sign-up/?utm_source=focalboard&utm_campaign=focalboard). After logging into Mattermost, select the menu in the top left corner and select **Boards**.
|
||||
**Mattermost Boards** combines project management tools with messaging and collaboration for teams of all sizes. To access and use **Mattermost Boards**, install or upgrade to Mattermost v6.0 or later as a [self-hosted server](https://docs.mattermost.com/guides/deployment.html?utm_source=github&utm_campaign=focalboard) or [Cloud server](https://mattermost.com/sign-up/?utm_source=github&utm_campaign=focalboard). After logging into Mattermost, select the menu in the top left corner and select **Boards**.
|
||||
|
||||
***Mattermost Boards** is installed and enabled by default in Mattermost v6.0 and later.*
|
||||
|
||||
|
|
|
@ -165,11 +165,11 @@ func (a *serviceAPIAdapter) GetFileInfo(fileID string) (*mm_model.FileInfo, erro
|
|||
//
|
||||
|
||||
func (a *serviceAPIAdapter) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *mm_model.WebsocketBroadcast) {
|
||||
a.api.clusterService.PublishWebSocketEvent(boardsProductID, event, payload, broadcast)
|
||||
a.api.clusterService.PublishWebSocketEvent(boardsProductName, event, payload, broadcast)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) PublishPluginClusterEvent(ev mm_model.PluginClusterEvent, opts mm_model.PluginClusterEventSendOptions) error {
|
||||
return a.api.clusterService.PublishPluginClusterEvent(boardsProductID, ev, opts)
|
||||
return a.api.clusterService.PublishPluginClusterEvent(boardsProductName, ev, opts)
|
||||
}
|
||||
|
||||
//
|
||||
|
@ -201,7 +201,7 @@ func (a *serviceAPIAdapter) GetLogger() mlog.LoggerIFace {
|
|||
//
|
||||
|
||||
func (a *serviceAPIAdapter) KVSetWithOptions(key string, value []byte, options mm_model.PluginKVSetOptions) (bool, error) {
|
||||
b, appErr := a.api.kvStoreService.SetPluginKeyWithOptions(boardsProductID, key, value, options)
|
||||
b, appErr := a.api.kvStoreService.SetPluginKeyWithOptions(boardsProductName, key, value, options)
|
||||
return b, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -201,7 +201,7 @@ export default class Plugin {
|
|||
let theme = mmStore.getState().entities.preferences.myPreferences.theme
|
||||
setMattermostTheme(theme)
|
||||
|
||||
const productID = process.env.TARGET_IS_PRODUCT ? 'com.mattermost.boards' : manifest.id
|
||||
const productID = process.env.TARGET_IS_PRODUCT ? 'boards' : manifest.id
|
||||
|
||||
// register websocket handlers
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${productID}_${ACTION_UPDATE_BOARD}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
|
|
|
@ -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}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -58,9 +58,9 @@ const commentsSlice = createSlice({
|
|||
state.comments[block.id] = block as CommentBlock
|
||||
state.commentsByCard[block.parentId] = state.commentsByCard[block.parentId] || []
|
||||
state.commentsByCard[block.parentId].push(block as CommentBlock)
|
||||
state.commentsByCard[block.parentId].sort((a, b) => a.createAt - b.createAt)
|
||||
}
|
||||
}
|
||||
Object.values(state.commentsByCard).forEach((comment) => comment.sort((a, b) => a.createAt - b.createAt))
|
||||
})
|
||||
builder.addCase(loadBoardData.fulfilled, (state, action) => {
|
||||
state.comments = {}
|
||||
|
@ -70,9 +70,9 @@ const commentsSlice = createSlice({
|
|||
state.comments[block.id] = block as CommentBlock
|
||||
state.commentsByCard[block.parentId] = state.commentsByCard[block.parentId] || []
|
||||
state.commentsByCard[block.parentId].push(block as CommentBlock)
|
||||
state.commentsByCard[block.parentId].sort((a, b) => a.createAt - b.createAt)
|
||||
}
|
||||
}
|
||||
Object.values(state.commentsByCard).forEach((comment) => comment.sort((a, b) => a.createAt - b.createAt))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in a new issue