You've already forked focalboard
mirror of
https://github.com/mattermost/focalboard.git
synced 2025-09-16 08:56:19 +02:00
Card APIs (#3760)
* cards apis wip * create card API * validate cards when creating * create card fixes * patch card wip * wip * unit test for createCard; CardPatch2BlockPatch * unit test for PatchCard * more APIs * unit tests for GetCardByID * register GetCard API * Set FOCALBOARD_UNIT_TESTING for integration tests * integration tests for CreateCard * more integration tests for CreateCard * integtration tests for PatchCard * fix integration tests for PatchCard * integration tests for GetCard * GetCards API wip * fix merge conflict * GetCards API and unit tests * fix linter issues * fix flaky unit test for mySQL * Update server/api/api.go Co-authored-by: Miguel de la Cruz <mgdelacroix@gmail.com> * Update server/api/api.go Co-authored-by: Miguel de la Cruz <mgdelacroix@gmail.com> * address review comments Co-authored-by: Mattermod <mattermod@users.noreply.github.com> Co-authored-by: Miguel de la Cruz <mgdelacroix@gmail.com>
This commit is contained in:
323
server/model/card.go
Normal file
323
server/model/card.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
"github.com/rivo/uniseg"
|
||||
)
|
||||
|
||||
var ErrBoardIDMismatch = errors.New("Board IDs do not match")
|
||||
|
||||
type ErrInvalidCard struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func NewErrInvalidCard(msg string) ErrInvalidCard {
|
||||
return ErrInvalidCard{
|
||||
msg: msg,
|
||||
}
|
||||
}
|
||||
|
||||
func (e ErrInvalidCard) Error() string {
|
||||
return fmt.Sprintf("invalid card, %s", e.msg)
|
||||
}
|
||||
|
||||
var ErrNotCardBlock = errors.New("not a card block")
|
||||
|
||||
type ErrInvalidFieldType struct {
|
||||
field string
|
||||
}
|
||||
|
||||
func (e ErrInvalidFieldType) Error() string {
|
||||
return fmt.Sprintf("invalid type for field '%s'", e.field)
|
||||
}
|
||||
|
||||
// Card represents a group of content blocks and properties.
|
||||
// swagger:model
|
||||
type Card struct {
|
||||
// The id for this card
|
||||
// required: false
|
||||
ID string `json:"id"`
|
||||
|
||||
// The id for board this card belongs to.
|
||||
// required: false
|
||||
BoardID string `json:"boardId"`
|
||||
|
||||
// The id for user who created this card
|
||||
// required: false
|
||||
CreatedBy string `json:"createdBy"`
|
||||
|
||||
// The id for user who last modified this card
|
||||
// required: false
|
||||
ModifiedBy string `json:"modifiedBy"`
|
||||
|
||||
// The display title
|
||||
// required: false
|
||||
Title string `json:"title"`
|
||||
|
||||
// An array of content block ids specifying the ordering of content for this card.
|
||||
// required: false
|
||||
ContentOrder []string `json:"contentOrder"`
|
||||
|
||||
// The icon of the card
|
||||
// required: false
|
||||
Icon string `json:"icon"`
|
||||
|
||||
// True if this card belongs to a template
|
||||
// required: false
|
||||
IsTemplate bool `json:"isTemplate"`
|
||||
|
||||
// A map of property ids to property values (option ids, strings, array of option ids)
|
||||
// required: false
|
||||
Properties map[string]any `json:"properties"`
|
||||
|
||||
// The creation time in milliseconds since the current epoch
|
||||
// required: false
|
||||
CreateAt int64 `json:"createAt"`
|
||||
|
||||
// The last modified time in milliseconds since the current epoch
|
||||
// required: false
|
||||
UpdateAt int64 `json:"updateAt"`
|
||||
|
||||
// The deleted time in milliseconds since the current epoch. Set to indicate this card is deleted
|
||||
// required: false
|
||||
DeleteAt int64 `json:"deleteAt"`
|
||||
}
|
||||
|
||||
// Populate populates a Card with default values.
|
||||
func (c *Card) Populate() {
|
||||
if c.ID == "" {
|
||||
c.ID = utils.NewID(utils.IDTypeCard)
|
||||
}
|
||||
if c.ContentOrder == nil {
|
||||
c.ContentOrder = make([]string, 0)
|
||||
}
|
||||
if c.Properties == nil {
|
||||
c.Properties = make(map[string]any)
|
||||
}
|
||||
now := utils.GetMillis()
|
||||
if c.CreateAt == 0 {
|
||||
c.CreateAt = now
|
||||
}
|
||||
if c.UpdateAt == 0 {
|
||||
c.UpdateAt = now
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Card) PopulateWithBoardID(boardID string) {
|
||||
c.BoardID = boardID
|
||||
c.Populate()
|
||||
}
|
||||
|
||||
// CheckValid returns an error if the Card has invalid field values.
|
||||
func (c *Card) CheckValid() error {
|
||||
if c.ID == "" {
|
||||
return ErrInvalidCard{"ID is missing"}
|
||||
}
|
||||
if c.BoardID == "" {
|
||||
return ErrInvalidCard{"BoardID is missing"}
|
||||
}
|
||||
if c.ContentOrder == nil {
|
||||
return ErrInvalidCard{"ContentOrder is missing"}
|
||||
}
|
||||
if uniseg.GraphemeClusterCount(c.Icon) > 1 {
|
||||
return ErrInvalidCard{"Icon can have only one grapheme"}
|
||||
}
|
||||
if c.Properties == nil {
|
||||
return ErrInvalidCard{"Properties"}
|
||||
}
|
||||
if c.CreateAt == 0 {
|
||||
return ErrInvalidCard{"CreateAt"}
|
||||
}
|
||||
if c.UpdateAt == 0 {
|
||||
return ErrInvalidCard{"UpdateAt"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CardPatch is a patch for modifying cards
|
||||
// swagger:model
|
||||
type CardPatch struct {
|
||||
// The display title
|
||||
// required: false
|
||||
Title *string `json:"title"`
|
||||
|
||||
// An array of content block ids specifying the ordering of content for this card.
|
||||
// required: false
|
||||
ContentOrder *[]string `json:"contentOrder"`
|
||||
|
||||
// The icon of the card
|
||||
// required: false
|
||||
Icon *string `json:"icon"`
|
||||
|
||||
// A map of property ids to property option ids to be updated
|
||||
// required: false
|
||||
UpdatedProperties map[string]any `json:"updatedProperties"`
|
||||
}
|
||||
|
||||
// Patch returns an updated version of the card.
|
||||
func (p *CardPatch) Patch(card *Card) *Card {
|
||||
if p.Title != nil {
|
||||
card.Title = *p.Title
|
||||
}
|
||||
|
||||
if p.ContentOrder != nil {
|
||||
card.ContentOrder = *p.ContentOrder
|
||||
}
|
||||
|
||||
if p.Icon != nil {
|
||||
card.Icon = *p.Icon
|
||||
}
|
||||
|
||||
if card.Properties == nil {
|
||||
card.Properties = make(map[string]any)
|
||||
}
|
||||
|
||||
// if there are properties marked for update, we replace the
|
||||
// existing ones or add them
|
||||
for propID, propVal := range p.UpdatedProperties {
|
||||
card.Properties[propID] = propVal
|
||||
}
|
||||
|
||||
return card
|
||||
}
|
||||
|
||||
// CheckValid returns an error if the CardPatch has invalid field values.
|
||||
func (p *CardPatch) CheckValid() error {
|
||||
if p.Icon != nil && uniseg.GraphemeClusterCount(*p.Icon) > 1 {
|
||||
return ErrInvalidCard{"Icon can have only one grapheme"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Card2Block converts a card to block using a shallow copy. Not needed once cards are first class entities.
|
||||
func Card2Block(card *Card) *Block {
|
||||
fields := make(map[string]interface{})
|
||||
|
||||
fields["contentOrder"] = card.ContentOrder
|
||||
fields["icon"] = card.Icon
|
||||
fields["isTemplate"] = card.IsTemplate
|
||||
fields["properties"] = card.Properties
|
||||
|
||||
return &Block{
|
||||
ID: card.ID,
|
||||
ParentID: card.BoardID,
|
||||
CreatedBy: card.CreatedBy,
|
||||
ModifiedBy: card.ModifiedBy,
|
||||
Schema: 1,
|
||||
Type: TypeCard,
|
||||
Title: card.Title,
|
||||
Fields: fields,
|
||||
CreateAt: card.CreateAt,
|
||||
UpdateAt: card.UpdateAt,
|
||||
DeleteAt: card.DeleteAt,
|
||||
BoardID: card.BoardID,
|
||||
}
|
||||
}
|
||||
|
||||
// Block2Card converts a block to a card. Not needed once cards are first class entities.
|
||||
func Block2Card(block *Block) (*Card, error) {
|
||||
if block.Type != TypeCard {
|
||||
return nil, fmt.Errorf("cannot convert block to card: %w", ErrNotCardBlock)
|
||||
}
|
||||
|
||||
contentOrder := make([]string, 0)
|
||||
icon := ""
|
||||
isTemplate := false
|
||||
properties := make(map[string]any)
|
||||
|
||||
if co, ok := block.Fields["contentOrder"]; ok {
|
||||
switch arr := co.(type) {
|
||||
case []any:
|
||||
for _, str := range arr {
|
||||
if id, ok := str.(string); ok {
|
||||
contentOrder = append(contentOrder, id)
|
||||
} else {
|
||||
return nil, ErrInvalidFieldType{"contentOrder item"}
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
contentOrder = append(contentOrder, arr...)
|
||||
default:
|
||||
return nil, ErrInvalidFieldType{"contentOrder"}
|
||||
}
|
||||
}
|
||||
|
||||
if iconAny, ok := block.Fields["icon"]; ok {
|
||||
if id, ok := iconAny.(string); ok {
|
||||
icon = id
|
||||
} else {
|
||||
return nil, ErrInvalidFieldType{"icon"}
|
||||
}
|
||||
}
|
||||
|
||||
if isTemplateAny, ok := block.Fields["isTemplate"]; ok {
|
||||
if b, ok := isTemplateAny.(bool); ok {
|
||||
isTemplate = b
|
||||
} else {
|
||||
return nil, ErrInvalidFieldType{"isTemplate"}
|
||||
}
|
||||
}
|
||||
|
||||
if props, ok := block.Fields["properties"]; ok {
|
||||
if propMap, ok := props.(map[string]any); ok {
|
||||
for k, v := range propMap {
|
||||
properties[k] = v
|
||||
}
|
||||
} else {
|
||||
return nil, ErrInvalidFieldType{"properties"}
|
||||
}
|
||||
}
|
||||
|
||||
card := &Card{
|
||||
ID: block.ID,
|
||||
BoardID: block.BoardID,
|
||||
CreatedBy: block.CreatedBy,
|
||||
ModifiedBy: block.ModifiedBy,
|
||||
Title: block.Title,
|
||||
ContentOrder: contentOrder,
|
||||
Icon: icon,
|
||||
IsTemplate: isTemplate,
|
||||
Properties: properties,
|
||||
CreateAt: block.CreateAt,
|
||||
UpdateAt: block.UpdateAt,
|
||||
DeleteAt: block.DeleteAt,
|
||||
}
|
||||
card.Populate()
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// CardPatch2BlockPatch converts a CardPatch to a BlockPatch. Not needed once cards are first class entities.
|
||||
func CardPatch2BlockPatch(cardPatch *CardPatch) (*BlockPatch, error) {
|
||||
if err := cardPatch.CheckValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blockPatch := &BlockPatch{
|
||||
Title: cardPatch.Title,
|
||||
}
|
||||
|
||||
updatedFields := make(map[string]any, 0)
|
||||
|
||||
if cardPatch.ContentOrder != nil {
|
||||
updatedFields["contentOrder"] = cardPatch.ContentOrder
|
||||
}
|
||||
if cardPatch.Icon != nil {
|
||||
updatedFields["icon"] = cardPatch.Icon
|
||||
}
|
||||
|
||||
properties := make(map[string]any)
|
||||
for k, v := range cardPatch.UpdatedProperties {
|
||||
properties[k] = v
|
||||
}
|
||||
|
||||
if len(properties) != 0 {
|
||||
updatedFields["properties"] = cardPatch.UpdatedProperties
|
||||
}
|
||||
|
||||
blockPatch.UpdatedFields = updatedFields
|
||||
|
||||
return blockPatch, nil
|
||||
}
|
84
server/model/card_test.go
Normal file
84
server/model/card_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBlock2Card(t *testing.T) {
|
||||
blockID := utils.NewID(utils.IDTypeCard)
|
||||
boardID := utils.NewID(utils.IDTypeBoard)
|
||||
userID := utils.NewID(utils.IDTypeUser)
|
||||
now := utils.GetMillis()
|
||||
|
||||
var fields map[string]any
|
||||
err := json.Unmarshal([]byte(sampleBlockFieldsJSON), &fields)
|
||||
require.NoError(t, err)
|
||||
|
||||
block := &Block{
|
||||
ID: blockID,
|
||||
ParentID: boardID,
|
||||
CreatedBy: userID,
|
||||
ModifiedBy: userID,
|
||||
Schema: 1,
|
||||
Type: TypeCard,
|
||||
Title: "My card title",
|
||||
Fields: fields,
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
DeleteAt: 0,
|
||||
BoardID: boardID,
|
||||
}
|
||||
|
||||
t.Run("Good block", func(t *testing.T) {
|
||||
card, err := Block2Card(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, block.ID, card.ID)
|
||||
assert.Equal(t, []string{"acdxa8r8aht85pyoeuj1ed7tu8w", "73urm1huoupd4idzkdq5yaeuyay", "ay6sogs9owtd9xbyn49qt3395ko"}, card.ContentOrder)
|
||||
assert.EqualValues(t, fields["icon"], card.Icon)
|
||||
assert.EqualValues(t, fields["isTemplate"], card.IsTemplate)
|
||||
assert.EqualValues(t, fields["properties"], card.Properties)
|
||||
})
|
||||
|
||||
t.Run("Not a card", func(t *testing.T) {
|
||||
blockNotCard := &Block{}
|
||||
|
||||
card, err := Block2Card(blockNotCard)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, card)
|
||||
})
|
||||
}
|
||||
|
||||
const sampleBlockFieldsJSON = `
|
||||
{
|
||||
"contentOrder":[
|
||||
"acdxa8r8aht85pyoeuj1ed7tu8w",
|
||||
"73urm1huoupd4idzkdq5yaeuyay",
|
||||
"ay6sogs9owtd9xbyn49qt3395ko"
|
||||
],
|
||||
"icon":"🎨",
|
||||
"isTemplate":false,
|
||||
"properties":{
|
||||
"aa7swu9zz3ofdkcna3h867cum4y":"212-444-1234",
|
||||
"af6fcbb8-ca56-4b73-83eb-37437b9a667d":"77c539af-309c-4db1-8329-d20ef7e9eacd",
|
||||
"aiwt9ibi8jjrf9hzi1xzk8no8mo":"foo",
|
||||
"aj65h4s6ghr6wgh3bnhqbzzmiaa":"77",
|
||||
"ajy6xbebzopojaenbnmfpgtdwso":"{\"from\":1660046400000}",
|
||||
"amc8wnk1xqj54rymkoqffhtw7ie":"zhqsoeqs1pg9i8gk81k9ryy83h",
|
||||
"aooz77t119y7xtfmoyeiy4up75c":"someone@example.com",
|
||||
"auskzaoaccsn55icuwarf4o3tfe":"https://www.google.com",
|
||||
"aydsk41h6cs1z7nmghaw16jqcia":[
|
||||
"aw565znut6zphbxqhbwyawiuggy",
|
||||
"aefd3pxciomrkur4rc6smg1usoc",
|
||||
"a6c96kwrqaskbtochq9wunmzweh",
|
||||
"atyexeuq993fwwb84bxoqixxqqr"
|
||||
],
|
||||
"d6b1249b-bc18-45fc-889e-bec48fce80ef":"9a090e33-b110-4268-8909-132c5002c90e",
|
||||
"d9725d14-d5a8-48e5-8de1-6f8c004a9680":"3245a32d-f688-463b-87f4-8e7142c1b397"
|
||||
}
|
||||
}`
|
Reference in New Issue
Block a user